From 07dfde2209374bb90ee22b6074ab51c0c23235d7 Mon Sep 17 00:00:00 2001 From: hagen-danswer Date: Wed, 27 Nov 2024 10:25:38 -0800 Subject: [PATCH] add continue in danswer button to slack bot responses (#3239) * all done except routing * fixed initial changes * added backend endpoint for duplicating a chat session from Slack * got chat duplication routing done * got login routing working * improved answer handling * finished all checks * finished all! * made sure it works with google oauth * dont remove that lol * fixed weird thing * bad comments --- ...1b118_add_web_ui_option_to_slack_config.py | 35 +++ backend/danswer/danswerbot/slack/blocks.py | 231 +++++++++++++++--- backend/danswer/danswerbot/slack/constants.py | 1 + .../slack/handlers/handle_buttons.py | 4 +- .../slack/handlers/handle_message.py | 4 +- .../slack/handlers/handle_regular_answer.py | 68 +----- backend/danswer/danswerbot/slack/utils.py | 13 +- backend/danswer/db/chat.py | 106 ++++++++ backend/danswer/db/models.py | 1 + backend/danswer/db/persona.py | 25 ++ backend/danswer/main.py | 3 +- .../one_shot_answer/answer_question.py | 14 +- backend/danswer/one_shot_answer/qa_utils.py | 28 +++ backend/danswer/server/manage/models.py | 1 + backend/danswer/server/manage/slack_bot.py | 4 + .../server/query_and_chat/chat_backend.py | 34 +++ .../[bot-id]/SlackChannelConfigsTable.tsx | 22 +- .../SlackChannelConfigCreationForm.tsx | 14 +- web/src/app/admin/bots/[bot-id]/lib.ts | 2 + web/src/app/admin/bots/[bot-id]/page.tsx | 1 - web/src/app/auth/login/EmailPasswordForm.tsx | 4 +- web/src/app/auth/login/page.tsx | 21 +- web/src/app/auth/signup/page.tsx | 18 +- web/src/app/chat/ChatPage.tsx | 46 +++- web/src/app/chat/lib.tsx | 4 +- web/src/lib/types.ts | 1 + web/src/lib/userSS.ts | 31 ++- 27 files changed, 590 insertions(+), 146 deletions(-) create mode 100644 backend/alembic/versions/93560ba1b118_add_web_ui_option_to_slack_config.py diff --git a/backend/alembic/versions/93560ba1b118_add_web_ui_option_to_slack_config.py b/backend/alembic/versions/93560ba1b118_add_web_ui_option_to_slack_config.py new file mode 100644 index 000000000..ab084aee3 --- /dev/null +++ b/backend/alembic/versions/93560ba1b118_add_web_ui_option_to_slack_config.py @@ -0,0 +1,35 @@ +"""add web ui option to slack config + +Revision ID: 93560ba1b118 +Revises: 6d562f86c78b +Create Date: 2024-11-24 06:36:17.490612 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "93560ba1b118" +down_revision = "6d562f86c78b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add show_continue_in_web_ui with default False to all existing channel_configs + op.execute( + """ + UPDATE slack_channel_config + SET channel_config = channel_config || '{"show_continue_in_web_ui": false}'::jsonb + WHERE NOT channel_config ? 'show_continue_in_web_ui' + """ + ) + + +def downgrade() -> None: + # Remove show_continue_in_web_ui from all channel_configs + op.execute( + """ + UPDATE slack_channel_config + SET channel_config = channel_config - 'show_continue_in_web_ui' + """ + ) diff --git a/backend/danswer/danswerbot/slack/blocks.py b/backend/danswer/danswerbot/slack/blocks.py index 1f6891574..a5e6868fd 100644 --- a/backend/danswer/danswerbot/slack/blocks.py +++ b/backend/danswer/danswerbot/slack/blocks.py @@ -18,20 +18,30 @@ from slack_sdk.models.blocks.block_elements import ImageElement from danswer.chat.models import DanswerQuote from danswer.configs.app_configs import DISABLE_GENERATIVE_AI +from danswer.configs.app_configs import WEB_DOMAIN from danswer.configs.constants import DocumentSource from danswer.configs.constants import SearchFeedbackType from danswer.configs.danswerbot_configs import DANSWER_BOT_NUM_DOCS_TO_DISPLAY from danswer.context.search.models import SavedSearchDoc +from danswer.danswerbot.slack.constants import CONTINUE_IN_WEB_UI_ACTION_ID from danswer.danswerbot.slack.constants import DISLIKE_BLOCK_ACTION_ID from danswer.danswerbot.slack.constants import FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID from danswer.danswerbot.slack.constants import FOLLOWUP_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import FOLLOWUP_BUTTON_RESOLVED_ACTION_ID from danswer.danswerbot.slack.constants import IMMEDIATE_RESOLVED_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import LIKE_BLOCK_ACTION_ID +from danswer.danswerbot.slack.formatting import format_slack_message from danswer.danswerbot.slack.icons import source_to_github_img_link +from danswer.danswerbot.slack.models import SlackMessageInfo +from danswer.danswerbot.slack.utils import build_continue_in_web_ui_id from danswer.danswerbot.slack.utils import build_feedback_id from danswer.danswerbot.slack.utils import remove_slack_text_interactions from danswer.danswerbot.slack.utils import translate_vespa_highlight_to_slack +from danswer.db.chat import get_chat_session_by_message_id +from danswer.db.engine import get_session_with_tenant +from danswer.db.models import ChannelConfig +from danswer.db.models import Persona +from danswer.one_shot_answer.models import OneShotQAResponse from danswer.utils.text_processing import decode_escapes from danswer.utils.text_processing import replace_whitespaces_w_space @@ -101,12 +111,12 @@ def _split_text(text: str, limit: int = 3000) -> list[str]: return chunks -def clean_markdown_link_text(text: str) -> str: +def _clean_markdown_link_text(text: str) -> str: # Remove any newlines within the text return text.replace("\n", " ").strip() -def build_qa_feedback_block( +def _build_qa_feedback_block( message_id: int, feedback_reminder_id: str | None = None ) -> Block: return ActionsBlock( @@ -115,7 +125,6 @@ def build_qa_feedback_block( ButtonElement( action_id=LIKE_BLOCK_ACTION_ID, text="👍 Helpful", - style="primary", value=feedback_reminder_id, ), ButtonElement( @@ -155,7 +164,7 @@ def get_document_feedback_blocks() -> Block: ) -def build_doc_feedback_block( +def _build_doc_feedback_block( message_id: int, document_id: str, document_rank: int, @@ -182,7 +191,7 @@ def get_restate_blocks( ] -def build_documents_blocks( +def _build_documents_blocks( documents: list[SavedSearchDoc], message_id: int | None, num_docs_to_display: int = DANSWER_BOT_NUM_DOCS_TO_DISPLAY, @@ -223,7 +232,7 @@ def build_documents_blocks( feedback: ButtonElement | dict = {} if message_id is not None: - feedback = build_doc_feedback_block( + feedback = _build_doc_feedback_block( message_id=message_id, document_id=d.document_id, document_rank=rank, @@ -241,7 +250,7 @@ def build_documents_blocks( return section_blocks -def build_sources_blocks( +def _build_sources_blocks( cited_documents: list[tuple[int, SavedSearchDoc]], num_docs_to_display: int = DANSWER_BOT_NUM_DOCS_TO_DISPLAY, ) -> list[Block]: @@ -286,7 +295,7 @@ def build_sources_blocks( + ([days_ago_str] if days_ago_str else []) ) - document_title = clean_markdown_link_text(doc_sem_id) + document_title = _clean_markdown_link_text(doc_sem_id) img_link = source_to_github_img_link(d.source_type) section_blocks.append( @@ -317,7 +326,50 @@ def build_sources_blocks( return section_blocks -def build_quotes_block( +def _priority_ordered_documents_blocks( + answer: OneShotQAResponse, +) -> list[Block]: + docs_response = answer.docs if answer.docs else None + top_docs = docs_response.top_documents if docs_response else [] + llm_doc_inds = answer.llm_selected_doc_indices or [] + llm_docs = [top_docs[i] for i in llm_doc_inds] + remaining_docs = [ + doc for idx, doc in enumerate(top_docs) if idx not in llm_doc_inds + ] + priority_ordered_docs = llm_docs + remaining_docs + if not priority_ordered_docs: + return [] + + document_blocks = _build_documents_blocks( + documents=priority_ordered_docs, + message_id=answer.chat_message_id, + ) + if document_blocks: + document_blocks = [DividerBlock()] + document_blocks + return document_blocks + + +def _build_citations_blocks( + answer: OneShotQAResponse, +) -> list[Block]: + docs_response = answer.docs if answer.docs else None + top_docs = docs_response.top_documents if docs_response else [] + citations = answer.citations or [] + cited_docs = [] + for citation in citations: + matching_doc = next( + (d for d in top_docs if d.document_id == citation.document_id), + None, + ) + if matching_doc: + cited_docs.append((citation.citation_num, matching_doc)) + + cited_docs.sort() + citations_block = _build_sources_blocks(cited_documents=cited_docs) + return citations_block + + +def _build_quotes_block( quotes: list[DanswerQuote], ) -> list[Block]: quote_lines: list[str] = [] @@ -359,58 +411,70 @@ def build_quotes_block( return [SectionBlock(text="*Relevant Snippets*\n" + "\n".join(quote_lines))] -def build_qa_response_blocks( - message_id: int | None, - answer: str | None, - quotes: list[DanswerQuote] | None, - source_filters: list[DocumentSource] | None, - time_cutoff: datetime | None, - favor_recent: bool, +def _build_qa_response_blocks( + answer: OneShotQAResponse, skip_quotes: bool = False, process_message_for_citations: bool = False, - skip_ai_feedback: bool = False, - feedback_reminder_id: str | None = None, ) -> list[Block]: + retrieval_info = answer.docs + if not retrieval_info: + # This should not happen, even with no docs retrieved, there is still info returned + raise RuntimeError("Failed to retrieve docs, cannot answer question.") + + formatted_answer = format_slack_message(answer.answer) if answer.answer else None + quotes = answer.quotes.quotes if answer.quotes else None + if DISABLE_GENERATIVE_AI: return [] quotes_blocks: list[Block] = [] filter_block: Block | None = None - if time_cutoff or favor_recent or source_filters: + if ( + retrieval_info.applied_time_cutoff + or retrieval_info.recency_bias_multiplier > 1 + or retrieval_info.applied_source_filters + ): filter_text = "Filters: " - if source_filters: - sources_str = ", ".join([s.value for s in source_filters]) + if retrieval_info.applied_source_filters: + sources_str = ", ".join( + [s.value for s in retrieval_info.applied_source_filters] + ) filter_text += f"`Sources in [{sources_str}]`" - if time_cutoff or favor_recent: + if ( + retrieval_info.applied_time_cutoff + or retrieval_info.recency_bias_multiplier > 1 + ): filter_text += " and " - if time_cutoff is not None: - time_str = time_cutoff.strftime("%b %d, %Y") + if retrieval_info.applied_time_cutoff is not None: + time_str = retrieval_info.applied_time_cutoff.strftime("%b %d, %Y") filter_text += f"`Docs Updated >= {time_str}` " - if favor_recent: - if time_cutoff is not None: + if retrieval_info.recency_bias_multiplier > 1: + if retrieval_info.applied_time_cutoff is not None: filter_text += "+ " filter_text += "`Prioritize Recently Updated Docs`" filter_block = SectionBlock(text=f"_{filter_text}_") - if not answer: + if not formatted_answer: answer_blocks = [ SectionBlock( text="Sorry, I was unable to find an answer, but I did find some potentially relevant docs 🤓" ) ] else: - answer_processed = decode_escapes(remove_slack_text_interactions(answer)) + answer_processed = decode_escapes( + remove_slack_text_interactions(formatted_answer) + ) if process_message_for_citations: answer_processed = _process_citations_for_slack(answer_processed) answer_blocks = [ SectionBlock(text=text) for text in _split_text(answer_processed) ] if quotes: - quotes_blocks = build_quotes_block(quotes) + quotes_blocks = _build_quotes_block(quotes) - # if no quotes OR `build_quotes_block()` did not give back any blocks + # if no quotes OR `_build_quotes_block()` did not give back any blocks if not quotes_blocks: quotes_blocks = [ SectionBlock( @@ -425,20 +489,37 @@ def build_qa_response_blocks( response_blocks.extend(answer_blocks) - if message_id is not None and not skip_ai_feedback: - response_blocks.append( - build_qa_feedback_block( - message_id=message_id, feedback_reminder_id=feedback_reminder_id - ) - ) - if not skip_quotes: response_blocks.extend(quotes_blocks) return response_blocks -def build_follow_up_block(message_id: int | None) -> ActionsBlock: +def _build_continue_in_web_ui_block( + tenant_id: str | None, + message_id: int | None, +) -> Block: + if message_id is None: + raise ValueError("No message id provided to build continue in web ui block") + with get_session_with_tenant(tenant_id) as db_session: + chat_session = get_chat_session_by_message_id( + db_session=db_session, + message_id=message_id, + ) + return ActionsBlock( + block_id=build_continue_in_web_ui_id(message_id), + elements=[ + ButtonElement( + action_id=CONTINUE_IN_WEB_UI_ACTION_ID, + text="Continue Chat in Danswer!", + style="primary", + url=f"{WEB_DOMAIN}/chat?slackChatId={chat_session.id}", + ), + ], + ) + + +def _build_follow_up_block(message_id: int | None) -> ActionsBlock: return ActionsBlock( block_id=build_feedback_id(message_id) if message_id is not None else None, elements=[ @@ -483,3 +564,77 @@ def build_follow_up_resolved_blocks( ] ) return [text_block, button_block] + + +def build_slack_response_blocks( + tenant_id: str | None, + message_info: SlackMessageInfo, + answer: OneShotQAResponse, + persona: Persona | None, + channel_conf: ChannelConfig | None, + use_citations: bool, + feedback_reminder_id: str | None, + skip_ai_feedback: bool = False, +) -> list[Block]: + """ + This function is a top level function that builds all the blocks for the Slack response. + It also handles combining all the blocks together. + """ + # If called with the DanswerBot slash command, the question is lost so we have to reshow it + restate_question_block = get_restate_blocks( + message_info.thread_messages[-1].message, message_info.is_bot_msg + ) + + answer_blocks = _build_qa_response_blocks( + answer=answer, + skip_quotes=persona is not None or use_citations, + process_message_for_citations=use_citations, + ) + + web_follow_up_block = [] + if channel_conf and channel_conf.get("show_continue_in_web_ui"): + web_follow_up_block.append( + _build_continue_in_web_ui_block( + tenant_id=tenant_id, + message_id=answer.chat_message_id, + ) + ) + + follow_up_block = [] + if channel_conf and channel_conf.get("follow_up_tags") is not None: + follow_up_block.append( + _build_follow_up_block(message_id=answer.chat_message_id) + ) + + ai_feedback_block = [] + if answer.chat_message_id is not None and not skip_ai_feedback: + ai_feedback_block.append( + _build_qa_feedback_block( + message_id=answer.chat_message_id, + feedback_reminder_id=feedback_reminder_id, + ) + ) + + citations_blocks = [] + document_blocks = [] + if use_citations: + # if citations are enabled, only show cited documents + citations_blocks = _build_citations_blocks(answer) + else: + document_blocks = _priority_ordered_documents_blocks(answer) + + citations_divider = [DividerBlock()] if citations_blocks else [] + buttons_divider = [DividerBlock()] if web_follow_up_block or follow_up_block else [] + + all_blocks = ( + restate_question_block + + answer_blocks + + ai_feedback_block + + citations_divider + + citations_blocks + + document_blocks + + buttons_divider + + web_follow_up_block + + follow_up_block + ) + return all_blocks diff --git a/backend/danswer/danswerbot/slack/constants.py b/backend/danswer/danswerbot/slack/constants.py index cf2b38032..6a5b3ed43 100644 --- a/backend/danswer/danswerbot/slack/constants.py +++ b/backend/danswer/danswerbot/slack/constants.py @@ -2,6 +2,7 @@ from enum import Enum LIKE_BLOCK_ACTION_ID = "feedback-like" DISLIKE_BLOCK_ACTION_ID = "feedback-dislike" +CONTINUE_IN_WEB_UI_ACTION_ID = "continue-in-web-ui" FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID = "feedback-doc-button" IMMEDIATE_RESOLVED_BUTTON_ACTION_ID = "immediate-resolved-button" FOLLOWUP_BUTTON_ACTION_ID = "followup-button" diff --git a/backend/danswer/danswerbot/slack/handlers/handle_buttons.py b/backend/danswer/danswerbot/slack/handlers/handle_buttons.py index ec4239799..9335b9687 100644 --- a/backend/danswer/danswerbot/slack/handlers/handle_buttons.py +++ b/backend/danswer/danswerbot/slack/handlers/handle_buttons.py @@ -28,7 +28,7 @@ from danswer.danswerbot.slack.models import SlackMessageInfo from danswer.danswerbot.slack.utils import build_feedback_id from danswer.danswerbot.slack.utils import decompose_action_id from danswer.danswerbot.slack.utils import fetch_group_ids_from_names -from danswer.danswerbot.slack.utils import fetch_user_ids_from_emails +from danswer.danswerbot.slack.utils import fetch_slack_user_ids_from_emails from danswer.danswerbot.slack.utils import get_channel_name_from_id from danswer.danswerbot.slack.utils import get_feedback_visibility from danswer.danswerbot.slack.utils import read_slack_thread @@ -267,7 +267,7 @@ def handle_followup_button( tag_names = slack_channel_config.channel_config.get("follow_up_tags") remaining = None if tag_names: - tag_ids, remaining = fetch_user_ids_from_emails( + tag_ids, remaining = fetch_slack_user_ids_from_emails( tag_names, client.web_client ) if remaining: diff --git a/backend/danswer/danswerbot/slack/handlers/handle_message.py b/backend/danswer/danswerbot/slack/handlers/handle_message.py index 6bec83def..1f19d0a70 100644 --- a/backend/danswer/danswerbot/slack/handlers/handle_message.py +++ b/backend/danswer/danswerbot/slack/handlers/handle_message.py @@ -13,7 +13,7 @@ from danswer.danswerbot.slack.handlers.handle_standard_answers import ( handle_standard_answers, ) from danswer.danswerbot.slack.models import SlackMessageInfo -from danswer.danswerbot.slack.utils import fetch_user_ids_from_emails +from danswer.danswerbot.slack.utils import fetch_slack_user_ids_from_emails from danswer.danswerbot.slack.utils import fetch_user_ids_from_groups from danswer.danswerbot.slack.utils import respond_in_thread from danswer.danswerbot.slack.utils import slack_usage_report @@ -184,7 +184,7 @@ def handle_message( send_to: list[str] | None = None missing_users: list[str] | None = None if respond_member_group_list: - send_to, missing_ids = fetch_user_ids_from_emails( + send_to, missing_ids = fetch_slack_user_ids_from_emails( respond_member_group_list, client ) diff --git a/backend/danswer/danswerbot/slack/handlers/handle_regular_answer.py b/backend/danswer/danswerbot/slack/handlers/handle_regular_answer.py index 3d5f013dc..926fd8582 100644 --- a/backend/danswer/danswerbot/slack/handlers/handle_regular_answer.py +++ b/backend/danswer/danswerbot/slack/handlers/handle_regular_answer.py @@ -7,7 +7,6 @@ from typing import TypeVar from retry import retry from slack_sdk import WebClient -from slack_sdk.models.blocks import DividerBlock from slack_sdk.models.blocks import SectionBlock from danswer.configs.app_configs import DISABLE_GENERATIVE_AI @@ -25,12 +24,7 @@ from danswer.context.search.enums import OptionalSearchSetting from danswer.context.search.models import BaseFilters from danswer.context.search.models import RerankingDetails from danswer.context.search.models import RetrievalDetails -from danswer.danswerbot.slack.blocks import build_documents_blocks -from danswer.danswerbot.slack.blocks import build_follow_up_block -from danswer.danswerbot.slack.blocks import build_qa_response_blocks -from danswer.danswerbot.slack.blocks import build_sources_blocks -from danswer.danswerbot.slack.blocks import get_restate_blocks -from danswer.danswerbot.slack.formatting import format_slack_message +from danswer.danswerbot.slack.blocks import build_slack_response_blocks from danswer.danswerbot.slack.handlers.utils import send_team_member_message from danswer.danswerbot.slack.models import SlackMessageInfo from danswer.danswerbot.slack.utils import respond_in_thread @@ -411,62 +405,16 @@ def handle_regular_answer( ) return True - # If called with the DanswerBot slash command, the question is lost so we have to reshow it - restate_question_block = get_restate_blocks(messages[-1].message, is_bot_msg) - formatted_answer = format_slack_message(answer.answer) if answer.answer else None - - answer_blocks = build_qa_response_blocks( - message_id=answer.chat_message_id, - answer=formatted_answer, - quotes=answer.quotes.quotes if answer.quotes else None, - source_filters=retrieval_info.applied_source_filters, - time_cutoff=retrieval_info.applied_time_cutoff, - favor_recent=retrieval_info.recency_bias_multiplier > 1, - # currently Personas don't support quotes - # if citations are enabled, also don't use quotes - skip_quotes=persona is not None or use_citations, - process_message_for_citations=use_citations, + all_blocks = build_slack_response_blocks( + tenant_id=tenant_id, + message_info=message_info, + answer=answer, + persona=persona, + channel_conf=channel_conf, + use_citations=use_citations, feedback_reminder_id=feedback_reminder_id, ) - # Get the chunks fed to the LLM only, then fill with other docs - llm_doc_inds = answer.llm_selected_doc_indices or [] - llm_docs = [top_docs[i] for i in llm_doc_inds] - remaining_docs = [ - doc for idx, doc in enumerate(top_docs) if idx not in llm_doc_inds - ] - priority_ordered_docs = llm_docs + remaining_docs - - document_blocks = [] - citations_block = [] - # if citations are enabled, only show cited documents - if use_citations: - citations = answer.citations or [] - cited_docs = [] - for citation in citations: - matching_doc = next( - (d for d in top_docs if d.document_id == citation.document_id), - None, - ) - if matching_doc: - cited_docs.append((citation.citation_num, matching_doc)) - - cited_docs.sort() - citations_block = build_sources_blocks(cited_documents=cited_docs) - elif priority_ordered_docs: - document_blocks = build_documents_blocks( - documents=priority_ordered_docs, - message_id=answer.chat_message_id, - ) - document_blocks = [DividerBlock()] + document_blocks - - all_blocks = ( - restate_question_block + answer_blocks + citations_block + document_blocks - ) - - if channel_conf and channel_conf.get("follow_up_tags") is not None: - all_blocks.append(build_follow_up_block(message_id=answer.chat_message_id)) - try: respond_in_thread( client=client, diff --git a/backend/danswer/danswerbot/slack/utils.py b/backend/danswer/danswerbot/slack/utils.py index e19ce8b68..cf6f1e1bf 100644 --- a/backend/danswer/danswerbot/slack/utils.py +++ b/backend/danswer/danswerbot/slack/utils.py @@ -3,9 +3,9 @@ import random import re import string import time +import uuid from typing import Any from typing import cast -from typing import Optional from retry import retry from slack_sdk import WebClient @@ -216,6 +216,13 @@ def build_feedback_id( return unique_prefix + ID_SEPARATOR + feedback_id +def build_continue_in_web_ui_id( + message_id: int, +) -> str: + unique_prefix = str(uuid.uuid4())[:10] + return unique_prefix + ID_SEPARATOR + str(message_id) + + def decompose_action_id(feedback_id: str) -> tuple[int, str | None, int | None]: """Decompose into query_id, document_id, document_rank, see above function""" try: @@ -313,7 +320,7 @@ def get_channel_name_from_id( raise e -def fetch_user_ids_from_emails( +def fetch_slack_user_ids_from_emails( user_emails: list[str], client: WebClient ) -> tuple[list[str], list[str]]: user_ids: list[str] = [] @@ -522,7 +529,7 @@ class SlackRateLimiter: self.last_reset_time = time.time() def notify( - self, client: WebClient, channel: str, position: int, thread_ts: Optional[str] + self, client: WebClient, channel: str, position: int, thread_ts: str | None ) -> None: respond_in_thread( client=client, diff --git a/backend/danswer/db/chat.py b/backend/danswer/db/chat.py index a76fcccdd..73d0a886f 100644 --- a/backend/danswer/db/chat.py +++ b/backend/danswer/db/chat.py @@ -3,6 +3,7 @@ from datetime import datetime from datetime import timedelta from uuid import UUID +from fastapi import HTTPException from sqlalchemy import delete from sqlalchemy import desc from sqlalchemy import func @@ -30,6 +31,7 @@ from danswer.db.models import SearchDoc from danswer.db.models import SearchDoc as DBSearchDoc from danswer.db.models import ToolCall from danswer.db.models import User +from danswer.db.persona import get_best_persona_id_for_user from danswer.db.pg_file_store import delete_lobj_by_name from danswer.file_store.models import FileDescriptor from danswer.llm.override_models import LLMOverride @@ -250,6 +252,50 @@ def create_chat_session( return chat_session +def duplicate_chat_session_for_user_from_slack( + db_session: Session, + user: User | None, + chat_session_id: UUID, +) -> ChatSession: + """ + This takes a chat session id for a session in Slack and: + - Creates a new chat session in the DB + - Tries to copy the persona from the original chat session + (if it is available to the user clicking the button) + - Sets the user to the given user (if provided) + """ + chat_session = get_chat_session_by_id( + chat_session_id=chat_session_id, + user_id=None, # Ignore user permissions for this + db_session=db_session, + ) + if not chat_session: + raise HTTPException(status_code=400, detail="Invalid Chat Session ID provided") + + # This enforces permissions and sets a default + new_persona_id = get_best_persona_id_for_user( + db_session=db_session, + user=user, + persona_id=chat_session.persona_id, + ) + + return create_chat_session( + db_session=db_session, + user_id=user.id if user else None, + persona_id=new_persona_id, + # Set this to empty string so the frontend will force a rename + description="", + llm_override=chat_session.llm_override, + prompt_override=chat_session.prompt_override, + # Chat sessions from Slack should put people in the chat UI, not the search + one_shot=False, + # Chat is in UI now so this is false + danswerbot_flow=False, + # Maybe we want this in the future to track if it was created from Slack + slack_thread_id=None, + ) + + def update_chat_session( db_session: Session, user_id: UUID | None, @@ -336,6 +382,28 @@ def get_chat_message( return chat_message +def get_chat_session_by_message_id( + db_session: Session, + message_id: int, +) -> ChatSession: + """ + Should only be used for Slack + Get the chat session associated with a specific message ID + Note: this ignores permission checks. + """ + stmt = select(ChatMessage).where(ChatMessage.id == message_id) + + result = db_session.execute(stmt) + chat_message = result.scalar_one_or_none() + + if chat_message is None: + raise ValueError( + f"Unable to find chat session associated with message ID: {message_id}" + ) + + return chat_message.chat_session + + def get_chat_messages_by_sessions( chat_session_ids: list[UUID], user_id: UUID | None, @@ -355,6 +423,44 @@ def get_chat_messages_by_sessions( return db_session.execute(stmt).scalars().all() +def add_chats_to_session_from_slack_thread( + db_session: Session, + slack_chat_session_id: UUID, + new_chat_session_id: UUID, +) -> None: + new_root_message = get_or_create_root_message( + chat_session_id=new_chat_session_id, + db_session=db_session, + ) + + for chat_message in get_chat_messages_by_sessions( + chat_session_ids=[slack_chat_session_id], + user_id=None, # Ignore user permissions for this + db_session=db_session, + skip_permission_check=True, + ): + if chat_message.message_type == MessageType.SYSTEM: + continue + # Duplicate the message + new_root_message = create_new_chat_message( + db_session=db_session, + chat_session_id=new_chat_session_id, + parent_message=new_root_message, + message=chat_message.message, + files=chat_message.files, + rephrased_query=chat_message.rephrased_query, + error=chat_message.error, + citations=chat_message.citations, + reference_docs=chat_message.search_docs, + tool_call=chat_message.tool_call, + prompt_id=chat_message.prompt_id, + token_count=chat_message.token_count, + message_type=chat_message.message_type, + alternate_assistant_id=chat_message.alternate_assistant_id, + overridden_model=chat_message.overridden_model, + ) + + def get_search_docs_for_chat_message( chat_message_id: int, db_session: Session ) -> list[SearchDoc]: diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index 76e70c2d2..4e1970a7b 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -1480,6 +1480,7 @@ class ChannelConfig(TypedDict): # If None then no follow up # If empty list, follow up with no tags follow_up_tags: NotRequired[list[str]] + show_continue_in_web_ui: NotRequired[bool] # defaults to False class SlackBotResponseType(str, PyEnum): diff --git a/backend/danswer/db/persona.py b/backend/danswer/db/persona.py index 98a50d50e..b71df2218 100644 --- a/backend/danswer/db/persona.py +++ b/backend/danswer/db/persona.py @@ -113,6 +113,31 @@ def fetch_persona_by_id( return persona +def get_best_persona_id_for_user( + db_session: Session, user: User | None, persona_id: int | None = None +) -> int | None: + if persona_id is not None: + stmt = select(Persona).where(Persona.id == persona_id).distinct() + stmt = _add_user_filters( + stmt=stmt, + user=user, + # We don't want to filter by editable here, we just want to see if the + # persona is usable by the user + get_editable=False, + ) + persona = db_session.scalars(stmt).one_or_none() + if persona: + return persona.id + + # If the persona is not found, or the slack bot is using doc sets instead of personas, + # we need to find the best persona for the user + # This is the persona with the highest display priority that the user has access to + stmt = select(Persona).order_by(Persona.display_priority.desc()).distinct() + stmt = _add_user_filters(stmt=stmt, user=user, get_editable=True) + persona = db_session.scalars(stmt).one_or_none() + return persona.id if persona else None + + def _get_persona_by_name( persona_name: str, user: User | None, db_session: Session ) -> Persona | None: diff --git a/backend/danswer/main.py b/backend/danswer/main.py index 3fd7072bb..a8fe531f7 100644 --- a/backend/danswer/main.py +++ b/backend/danswer/main.py @@ -26,6 +26,7 @@ from danswer.auth.schemas import UserRead from danswer.auth.schemas import UserUpdate from danswer.auth.users import auth_backend from danswer.auth.users import BasicAuthenticationError +from danswer.auth.users import create_danswer_oauth_router from danswer.auth.users import fastapi_users from danswer.configs.app_configs import APP_API_PREFIX from danswer.configs.app_configs import APP_HOST @@ -323,7 +324,7 @@ def get_application() -> FastAPI: oauth_client = GoogleOAuth2(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) include_router_with_global_prefix_prepended( application, - fastapi_users.get_oauth_router( + create_danswer_oauth_router( oauth_client, auth_backend, USER_AUTH_SECRET, diff --git a/backend/danswer/one_shot_answer/answer_question.py b/backend/danswer/one_shot_answer/answer_question.py index 9f8ce9923..826673acb 100644 --- a/backend/danswer/one_shot_answer/answer_question.py +++ b/backend/danswer/one_shot_answer/answer_question.py @@ -47,6 +47,7 @@ from danswer.one_shot_answer.models import DirectQARequest from danswer.one_shot_answer.models import OneShotQAResponse from danswer.one_shot_answer.models import QueryRephrase from danswer.one_shot_answer.qa_utils import combine_message_thread +from danswer.one_shot_answer.qa_utils import slackify_message_thread from danswer.secondary_llm_flows.answer_validation import get_answer_validity from danswer.secondary_llm_flows.query_expansion import thread_based_query_rephrase from danswer.server.query_and_chat.models import ChatMessageDetail @@ -194,13 +195,22 @@ def stream_answer_objects( ) prompt = persona.prompts[0] + user_message_str = query_msg.message + # For this endpoint, we only save one user message to the chat session + # However, for slackbot, we want to include the history of the entire thread + if danswerbot_flow: + # Right now, we only support bringing over citations and search docs + # from the last message in the thread, not the entire thread + # in the future, we may want to retrieve the entire thread + user_message_str = slackify_message_thread(query_req.messages) + # Create the first User query message new_user_message = create_new_chat_message( chat_session_id=chat_session.id, parent_message=root_message, prompt_id=query_req.prompt_id, - message=query_msg.message, - token_count=len(llm_tokenizer.encode(query_msg.message)), + message=user_message_str, + token_count=len(llm_tokenizer.encode(user_message_str)), message_type=MessageType.USER, db_session=db_session, commit=True, diff --git a/backend/danswer/one_shot_answer/qa_utils.py b/backend/danswer/one_shot_answer/qa_utils.py index 6fbad99ef..8770a3b14 100644 --- a/backend/danswer/one_shot_answer/qa_utils.py +++ b/backend/danswer/one_shot_answer/qa_utils.py @@ -51,3 +51,31 @@ def combine_message_thread( total_token_count += message_token_count return "\n\n".join(message_strs) + + +def slackify_message(message: ThreadMessage) -> str: + if message.role != MessageType.USER: + return message.message + + return f"{message.sender or 'Unknown User'} said in Slack:\n{message.message}" + + +def slackify_message_thread(messages: list[ThreadMessage]) -> str: + if not messages: + return "" + + message_strs: list[str] = [] + for message in messages: + if message.role == MessageType.USER: + message_text = ( + f"{message.sender or 'Unknown User'} said in Slack:\n{message.message}" + ) + elif message.role == MessageType.ASSISTANT: + message_text = f"DanswerBot said in Slack:\n{message.message}" + else: + message_text = ( + f"{message.role.value.upper()} said in Slack:\n{message.message}" + ) + message_strs.append(message_text) + + return "\n\n".join(message_strs) diff --git a/backend/danswer/server/manage/models.py b/backend/danswer/server/manage/models.py index 74a3a774e..9c2960741 100644 --- a/backend/danswer/server/manage/models.py +++ b/backend/danswer/server/manage/models.py @@ -156,6 +156,7 @@ class SlackChannelConfigCreationRequest(BaseModel): channel_name: str respond_tag_only: bool = False respond_to_bots: bool = False + show_continue_in_web_ui: bool = False enable_auto_filters: bool = False # If no team members, assume respond in the channel to everyone respond_member_group_list: list[str] = Field(default_factory=list) diff --git a/backend/danswer/server/manage/slack_bot.py b/backend/danswer/server/manage/slack_bot.py index 036f2fca0..60a7edaae 100644 --- a/backend/danswer/server/manage/slack_bot.py +++ b/backend/danswer/server/manage/slack_bot.py @@ -80,6 +80,10 @@ def _form_channel_config( if follow_up_tags is not None: channel_config["follow_up_tags"] = follow_up_tags + channel_config[ + "show_continue_in_web_ui" + ] = slack_channel_config_creation_request.show_continue_in_web_ui + channel_config[ "respond_to_bots" ] = slack_channel_config_creation_request.respond_to_bots diff --git a/backend/danswer/server/query_and_chat/chat_backend.py b/backend/danswer/server/query_and_chat/chat_backend.py index c4728336c..954728c32 100644 --- a/backend/danswer/server/query_and_chat/chat_backend.py +++ b/backend/danswer/server/query_and_chat/chat_backend.py @@ -27,9 +27,11 @@ from danswer.configs.app_configs import WEB_DOMAIN from danswer.configs.constants import FileOrigin from danswer.configs.constants import MessageType from danswer.configs.model_configs import LITELLM_PASS_THROUGH_HEADERS +from danswer.db.chat import add_chats_to_session_from_slack_thread from danswer.db.chat import create_chat_session from danswer.db.chat import create_new_chat_message from danswer.db.chat import delete_chat_session +from danswer.db.chat import duplicate_chat_session_for_user_from_slack from danswer.db.chat import get_chat_message from danswer.db.chat import get_chat_messages_by_session from danswer.db.chat import get_chat_session_by_id @@ -532,6 +534,38 @@ def seed_chat( ) +class SeedChatFromSlackRequest(BaseModel): + chat_session_id: UUID + + +class SeedChatFromSlackResponse(BaseModel): + redirect_url: str + + +@router.post("/seed-chat-session-from-slack") +def seed_chat_from_slack( + chat_seed_request: SeedChatFromSlackRequest, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> SeedChatFromSlackResponse: + slack_chat_session_id = chat_seed_request.chat_session_id + new_chat_session = duplicate_chat_session_for_user_from_slack( + db_session=db_session, + user=user, + chat_session_id=slack_chat_session_id, + ) + + add_chats_to_session_from_slack_thread( + db_session=db_session, + slack_chat_session_id=slack_chat_session_id, + new_chat_session_id=new_chat_session.id, + ) + + return SeedChatFromSlackResponse( + redirect_url=f"{WEB_DOMAIN}/chat?chatId={new_chat_session.id}" + ) + + """File upload""" diff --git a/web/src/app/admin/bots/[bot-id]/SlackChannelConfigsTable.tsx b/web/src/app/admin/bots/[bot-id]/SlackChannelConfigsTable.tsx index 632e41aa3..1f99b7ca2 100644 --- a/web/src/app/admin/bots/[bot-id]/SlackChannelConfigsTable.tsx +++ b/web/src/app/admin/bots/[bot-id]/SlackChannelConfigsTable.tsx @@ -60,21 +60,24 @@ export function SlackChannelConfigsTable({ .slice(numToDisplay * (page - 1), numToDisplay * page) .map((slackChannelConfig) => { return ( - + { + window.location.href = `/admin/bots/${slackBotId}/channels/${slackChannelConfig.id}`; + }} + >
- +
- +
{"#" + slackChannelConfig.channel_config.channel_name}
- + e.stopPropagation()}> {slackChannelConfig.persona && !isPersonaASlackBotPersona(slackChannelConfig.persona) ? ( - + e.stopPropagation()}>
{ + onClick={async (e) => { + e.stopPropagation(); const response = await deleteSlackChannelConfig( slackChannelConfig.id ); diff --git a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx index 9a8caad2a..5b51e8cf6 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx @@ -81,6 +81,11 @@ export const SlackChannelConfigCreationForm = ({ respond_to_bots: existingSlackChannelConfig?.channel_config?.respond_to_bots || false, + show_continue_in_web_ui: + // If we're updating, we want to keep the existing value + // Otherwise, we want to default to true + existingSlackChannelConfig?.channel_config + ?.show_continue_in_web_ui ?? !isUpdate, enable_auto_filters: existingSlackChannelConfig?.enable_auto_filters || false, respond_member_group_list: @@ -119,6 +124,7 @@ export const SlackChannelConfigCreationForm = ({ questionmark_prefilter_enabled: Yup.boolean().required(), respond_tag_only: Yup.boolean().required(), respond_to_bots: Yup.boolean().required(), + show_continue_in_web_ui: Yup.boolean().required(), enable_auto_filters: Yup.boolean().required(), respond_member_group_list: Yup.array().of(Yup.string()).required(), still_need_help_enabled: Yup.boolean().required(), @@ -270,7 +276,13 @@ export const SlackChannelConfigCreationForm = ({ {showAdvancedOptions && (
-
+ +
{ const searchParams = await props.searchParams; const autoRedirectDisabled = searchParams?.disableAutoRedirect === "true"; + const nextUrl = Array.isArray(searchParams?.next) + ? searchParams?.next[0] + : searchParams?.next || null; // catch cases where the backend is completely unreachable here // without try / catch, will just raise an exception and the page @@ -37,10 +40,6 @@ const Page = async (props: { console.log(`Some fetch failed for the login page - ${e}`); } - const nextUrl = Array.isArray(searchParams?.next) - ? searchParams?.next[0] - : searchParams?.next || null; - // simply take the user to the home page if Auth is disabled if (authTypeMetadata?.authType === "disabled") { return redirect("/"); @@ -100,12 +99,15 @@ const Page = async (props: { or
- +
Don't have an account?{" "} - + Create an account @@ -120,11 +122,14 @@ const Page = async (props: {
- +
Don't have an account?{" "} - + Create an account diff --git a/web/src/app/auth/signup/page.tsx b/web/src/app/auth/signup/page.tsx index 223faff33..94a7d1967 100644 --- a/web/src/app/auth/signup/page.tsx +++ b/web/src/app/auth/signup/page.tsx @@ -15,7 +15,14 @@ import AuthFlowContainer from "@/components/auth/AuthFlowContainer"; import ReferralSourceSelector from "./ReferralSourceSelector"; import { Separator } from "@/components/ui/separator"; -const Page = async () => { +const Page = async (props: { + searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; +}) => { + const searchParams = await props.searchParams; + const nextUrl = Array.isArray(searchParams?.next) + ? searchParams?.next[0] + : searchParams?.next || null; + // catch cases where the backend is completely unreachable here // without try / catch, will just raise an exception and the page // will not render @@ -86,12 +93,19 @@ const Page = async () => {
Already have an account?{" "} - + Log In diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 0bb3ebfa9..94f336ba8 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -161,6 +161,8 @@ export function ChatPage({ const { user, isAdmin, isLoadingUser, refreshUser } = useUser(); + const slackChatId = searchParams.get("slackChatId"); + const existingChatIdRaw = searchParams.get("chatId"); const [sendOnLoad, setSendOnLoad] = useState( searchParams.get(SEARCH_PARAM_NAMES.SEND_ON_LOAD) @@ -403,6 +405,7 @@ export function ChatPage({ } return; } + setIsReady(true); const shouldScrollToBottom = visibleRange.get(existingChatSessionId) === undefined || visibleRange.get(existingChatSessionId)?.end == 0; @@ -468,9 +471,12 @@ export function ChatPage({ }); // force re-name if the chat session doesn't have one if (!chatSession.description) { - await nameChatSession(existingChatSessionId, seededMessage); + await nameChatSession(existingChatSessionId); refreshChatSessions(); } + } else if (newMessageHistory.length === 2 && !chatSession.description) { + await nameChatSession(existingChatSessionId); + refreshChatSessions(); } } @@ -1428,7 +1434,7 @@ export function ChatPage({ if (!searchParamBasedChatSessionName) { await new Promise((resolve) => setTimeout(resolve, 200)); - await nameChatSession(currChatSessionId, currMessage); + await nameChatSession(currChatSessionId); refreshChatSessions(); } @@ -1810,6 +1816,42 @@ export function ChatPage({ }; } + useEffect(() => { + const handleSlackChatRedirect = async () => { + if (!slackChatId) return; + + // Set isReady to false before starting retrieval to display loading text + setIsReady(false); + + try { + const response = await fetch("/api/chat/seed-chat-session-from-slack", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_session_id: slackChatId, + }), + }); + + if (!response.ok) { + throw new Error("Failed to seed chat from Slack"); + } + + const data = await response.json(); + router.push(data.redirect_url); + } catch (error) { + console.error("Error seeding chat from Slack:", error); + setPopup({ + message: "Failed to load chat from Slack", + type: "error", + }); + } + }; + + handleSlackChatRedirect(); + }, [searchParams, router]); + return ( <> diff --git a/web/src/app/chat/lib.tsx b/web/src/app/chat/lib.tsx index a64c605a0..005297764 100644 --- a/web/src/app/chat/lib.tsx +++ b/web/src/app/chat/lib.tsx @@ -203,7 +203,7 @@ export async function* sendMessage({ yield* handleSSEStream(response); } -export async function nameChatSession(chatSessionId: string, message: string) { +export async function nameChatSession(chatSessionId: string) { const response = await fetch("/api/chat/rename-chat-session", { method: "PUT", headers: { @@ -212,7 +212,6 @@ export async function nameChatSession(chatSessionId: string, message: string) { body: JSON.stringify({ chat_session_id: chatSessionId, name: null, - first_message: message, }), }); return response; @@ -263,7 +262,6 @@ export async function renameChatSession( body: JSON.stringify({ chat_session_id: chatSessionId, name: newName, - first_message: null, }), }); return response; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 8ea6047dd..7fe1402c5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -208,6 +208,7 @@ export interface ChannelConfig { channel_name: string; respond_tag_only?: boolean; respond_to_bots?: boolean; + show_continue_in_web_ui?: boolean; respond_member_group_list?: string[]; answer_filters?: AnswerFilterOption[]; follow_up_tags?: string[]; diff --git a/web/src/lib/userSS.ts b/web/src/lib/userSS.ts index 906f23fa8..b0c960939 100644 --- a/web/src/lib/userSS.ts +++ b/web/src/lib/userSS.ts @@ -62,12 +62,17 @@ const getOIDCAuthUrlSS = async (nextUrl: string | null): Promise => { return data.authorization_url; }; -const getGoogleOAuthUrlSS = async (): Promise => { - const res = await fetch(buildUrl(`/auth/oauth/authorize`), { - headers: { - cookie: processCookies(await cookies()), - }, - }); +const getGoogleOAuthUrlSS = async (nextUrl: string | null): Promise => { + const res = await fetch( + buildUrl( + `/auth/oauth/authorize${nextUrl ? `?next=${encodeURIComponent(nextUrl)}` : ""}` + ), + { + headers: { + cookie: processCookies(await cookies()), + }, + } + ); if (!res.ok) { throw new Error("Failed to fetch data"); } @@ -76,8 +81,12 @@ const getGoogleOAuthUrlSS = async (): Promise => { return data.authorization_url; }; -const getSAMLAuthUrlSS = async (): Promise => { - const res = await fetch(buildUrl("/auth/saml/authorize")); +const getSAMLAuthUrlSS = async (nextUrl: string | null): Promise => { + const res = await fetch( + buildUrl( + `/auth/saml/authorize${nextUrl ? `?next=${encodeURIComponent(nextUrl)}` : ""}` + ) + ); if (!res.ok) { throw new Error("Failed to fetch data"); } @@ -97,13 +106,13 @@ export const getAuthUrlSS = async ( case "basic": return ""; case "google_oauth": { - return await getGoogleOAuthUrlSS(); + return await getGoogleOAuthUrlSS(nextUrl); } case "cloud": { - return await getGoogleOAuthUrlSS(); + return await getGoogleOAuthUrlSS(nextUrl); } case "saml": { - return await getSAMLAuthUrlSS(); + return await getSAMLAuthUrlSS(nextUrl); } case "oidc": { return await getOIDCAuthUrlSS(nextUrl);