Allow persona usage for Slack bots

This commit is contained in:
Weves
2023-12-04 16:26:34 -08:00
committed by Chris Weaver
parent 5aa2de7a40
commit f7172612e1
16 changed files with 671 additions and 313 deletions

View File

@@ -89,6 +89,7 @@ def handle_message(
sender_id = message_info.sender
bipass_filters = message_info.bipass_filters
is_bot_msg = message_info.is_bot_msg
persona = channel_config.persona if channel_config else None
logger = cast(
logging.Logger,
@@ -96,9 +97,9 @@ def handle_message(
)
document_set_names: list[str] | None = None
if channel_config and channel_config.persona:
if persona:
document_set_names = [
document_set.name for document_set in channel_config.persona.document_sets
document_set.name for document_set in persona.document_sets
]
# List of user id to send message to, if None, send to everyone in channel
@@ -194,7 +195,10 @@ def handle_message(
# for an existing chat session associated with this thread
with Session(get_sqlalchemy_engine()) as db_session:
chat_session = create_chat_session(
db_session=db_session, description="", user_id=None
db_session=db_session,
description="",
user_id=None,
persona_id=persona.id if persona else None,
)
chat_session_id = chat_session.id

View File

@@ -325,6 +325,7 @@ def upsert_persona(
default_persona: bool = False,
document_sets: list[DocumentSetDBModel] | None = None,
commit: bool = True,
overwrite_duplicate_named_persona: bool = False,
) -> Persona:
persona = db_session.query(Persona).filter_by(id=persona_id).first()
if persona and persona.deleted:
@@ -336,9 +337,13 @@ def upsert_persona(
persona = fetch_default_persona_by_name(name, db_session)
else:
# only one persona with the same name should exist
if fetch_persona_by_name(name, db_session):
persona_with_same_name = fetch_persona_by_name(name, db_session)
if persona_with_same_name and not overwrite_duplicate_named_persona:
raise ValueError("Trying to create a persona with a duplicate name")
# set "existing" persona to the one with the same name so we can override it
persona = persona_with_same_name
if persona:
persona.name = name
persona.description = description

View File

@@ -27,7 +27,7 @@ def _cleanup_relationships(db_session: Session, persona_id: int) -> None:
db_session.delete(rel)
def _create_slack_bot_persona(
def create_slack_bot_persona(
db_session: Session,
channel_names: list[str],
document_sets: list[int],
@@ -47,6 +47,7 @@ def _create_slack_bot_persona(
default_persona=False,
db_session=db_session,
commit=False,
overwrite_duplicate_named_persona=True,
)
if existing_persona_id:
@@ -62,20 +63,12 @@ def _create_slack_bot_persona(
def insert_slack_bot_config(
document_sets: list[int],
persona_id: int | None,
channel_config: ChannelConfig,
db_session: Session,
) -> SlackBotConfig:
persona = None
if document_sets:
persona = _create_slack_bot_persona(
db_session=db_session,
channel_names=channel_config["channel_names"],
document_sets=document_sets,
)
slack_bot_config = SlackBotConfig(
persona_id=persona.id if persona else None,
persona_id=persona_id,
channel_config=channel_config,
)
db_session.add(slack_bot_config)
@@ -86,7 +79,7 @@ def insert_slack_bot_config(
def update_slack_bot_config(
slack_bot_config_id: int,
document_sets: list[int],
persona_id: int | None,
channel_config: ChannelConfig,
db_session: Session,
) -> SlackBotConfig:
@@ -97,31 +90,29 @@ def update_slack_bot_config(
raise ValueError(
f"Unable to find slack bot config with ID {slack_bot_config_id}"
)
# get the existing persona id before updating the object
existing_persona_id = slack_bot_config.persona_id
persona = None
if document_sets:
persona = _create_slack_bot_persona(
db_session=db_session,
channel_names=channel_config["channel_names"],
document_sets=document_sets,
existing_persona_id=slack_bot_config.persona_id,
# 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_bot_config.persona_id = persona_id
slack_bot_config.channel_config = channel_config
# 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)
)
else:
# if no document sets and an existing persona exists, then
# remove persona + persona -> document set relationships
if existing_persona_id:
# if the existing persona was one created just for use with this Slack Bot,
# 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
)
existing_persona = db_session.scalar(
select(Persona).where(Persona.id == existing_persona_id)
)
db_session.delete(existing_persona)
slack_bot_config.persona_id = persona.id if persona else None
slack_bot_config.channel_config = channel_config
db_session.commit()
return slack_bot_config
@@ -141,11 +132,30 @@ def remove_slack_bot_config(
existing_persona_id = slack_bot_config.persona_id
if existing_persona_id:
_cleanup_relationships(db_session=db_session, persona_id=existing_persona_id)
existing_persona = db_session.scalar(
select(Persona).where(Persona.id == existing_persona_id)
)
# if the existing persona was one created just for use with this Slack Bot,
# 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.delete(existing_persona)
db_session.delete(slack_bot_config)
db_session.commit()
def fetch_slack_bot_config(
db_session: Session, slack_bot_config_id: int
) -> SlackBotConfig | None:
return db_session.scalar(
select(SlackBotConfig).where(SlackBotConfig.id == slack_bot_config_id)
)
def fetch_slack_bot_configs(db_session: Session) -> Sequence[SlackBotConfig]:
return db_session.scalars(select(SlackBotConfig)).all()

View File

@@ -15,10 +15,12 @@ from danswer.db.chat import fetch_chat_session_by_id
from danswer.db.feedback import create_query_event
from danswer.db.feedback import update_query_event_llm_answer
from danswer.db.feedback import update_query_event_retrieved_documents
from danswer.db.models import Persona
from danswer.db.models import User
from danswer.direct_qa.factory import get_default_qa_model
from danswer.direct_qa.factory import get_qa_model_for_persona
from danswer.direct_qa.interfaces import DanswerAnswerPiece
from danswer.direct_qa.interfaces import QAModel
from danswer.direct_qa.interfaces import StreamingError
from danswer.direct_qa.models import LLMMetricsContainer
from danswer.direct_qa.qa_utils import get_chunks_for_qa
@@ -45,6 +47,13 @@ from danswer.utils.timing import log_generator_function_time
logger = setup_logger()
def _get_qa_model(persona: Persona | None) -> QAModel:
if persona and (persona.hint_text or persona.system_text):
return get_qa_model_for_persona(persona=persona)
return get_default_qa_model()
@log_function_time()
def answer_qa_query(
new_message_request: NewMessageRequest,
@@ -74,12 +83,30 @@ def answer_qa_query(
user_id=user.id if user is not None else None,
db_session=db_session,
)
chat_session = fetch_chat_session_by_id(
chat_session_id=new_message_request.chat_session_id, db_session=db_session
)
persona = chat_session.persona
persona_skip_llm_chunk_filter = (
not persona.apply_llm_relevance_filter if persona else None
)
persona_num_chunks = persona.num_chunks if persona else None
if persona:
logger.info(f"Using persona: {persona.name}")
logger.info(
"Persona retrieval settings: skip_llm_chunk_filter: "
f"{persona_skip_llm_chunk_filter}, "
f"num_chunks: {persona_num_chunks}"
)
retrieval_request, predicted_search_type, predicted_flow = retrieval_preprocessing(
new_message_request=new_message_request,
user=user,
db_session=db_session,
bypass_acl=bypass_acl,
skip_llm_chunk_filter=persona_skip_llm_chunk_filter
if persona_skip_llm_chunk_filter is not None
else DISABLE_LLM_CHUNK_FILTER,
)
# Set flow as search so frontend doesn't ask the user if they want to run QA over more docs
@@ -123,10 +150,7 @@ def answer_qa_query(
)
try:
qa_model = get_default_qa_model(
timeout=answer_generation_timeout,
real_time_flow=new_message_request.real_time,
)
qa_model = _get_qa_model(persona)
except Exception as e:
return partial_response(
answer=None,
@@ -294,10 +318,7 @@ def answer_qa_query_stream(
return
try:
if not persona:
qa_model = get_default_qa_model()
else:
qa_model = get_qa_model_for_persona(persona=persona)
qa_model = _get_qa_model(persona)
except Exception as e:
logger.exception("Unable to get QA model")
error = StreamingError(error=str(e))

View File

@@ -1,4 +1,7 @@
from typing import Any
from pydantic import BaseModel
from pydantic import root_validator
from pydantic import validator
from danswer.auth.schemas import UserRole
@@ -6,7 +9,7 @@ from danswer.configs.constants import AuthType
from danswer.danswerbot.slack.config import VALID_SLACK_FILTERS
from danswer.db.models import AllowedAnswerFilters
from danswer.db.models import ChannelConfig
from danswer.server.features.document_set.models import DocumentSet
from danswer.server.features.persona.models import PersonaSnapshot
class VersionResponse(BaseModel):
@@ -62,7 +65,8 @@ class SlackBotConfigCreationRequest(BaseModel):
# in the future, `document_sets` will probably be replaced
# by an optional `PersonaSnapshot` object. Keeping it like this
# for now for simplicity / speed of development
document_sets: list[int]
document_sets: list[int] | None
persona_id: int | None # NOTE: only one of `document_sets` / `persona_id` should be set
channel_names: list[str]
respond_tag_only: bool = False
# If no team members, assume respond in the channel to everyone
@@ -77,12 +81,17 @@ class SlackBotConfigCreationRequest(BaseModel):
)
return value
@root_validator
def validate_document_sets_and_persona_id(
cls, values: dict[str, Any]
) -> dict[str, Any]:
if values.get("document_sets") and values.get("persona_id"):
raise ValueError("Only one of `document_sets` / `persona_id` should be set")
return values
class SlackBotConfig(BaseModel):
id: int
# currently, a persona is created for each slack bot config
# in the future, `document_sets` will probably be replaced
# by an optional `PersonaSnapshot` object. Keeping it like this
# for now for simplicity / speed of development
document_sets: list[DocumentSet]
persona: PersonaSnapshot | None
channel_config: ChannelConfig

View File

@@ -10,12 +10,14 @@ from danswer.danswerbot.slack.tokens import save_tokens
from danswer.db.engine import get_session
from danswer.db.models import ChannelConfig
from danswer.db.models import User
from danswer.db.slack_bot_config import create_slack_bot_persona
from danswer.db.slack_bot_config import fetch_slack_bot_config
from danswer.db.slack_bot_config import fetch_slack_bot_configs
from danswer.db.slack_bot_config import insert_slack_bot_config
from danswer.db.slack_bot_config import remove_slack_bot_config
from danswer.db.slack_bot_config import update_slack_bot_config
from danswer.dynamic_configs.interface import ConfigNotFoundError
from danswer.server.features.document_set.models import DocumentSet
from danswer.server.features.persona.models import PersonaSnapshot
from danswer.server.manage.models import SlackBotConfig
from danswer.server.manage.models import SlackBotConfigCreationRequest
from danswer.server.manage.models import SlackBotTokens
@@ -83,19 +85,29 @@ def create_slack_bot_config(
slack_bot_config_creation_request, None, db_session
)
persona_id = None
if slack_bot_config_creation_request.persona_id is not None:
persona_id = slack_bot_config_creation_request.persona_id
elif slack_bot_config_creation_request.document_sets:
persona_id = create_slack_bot_persona(
db_session=db_session,
channel_names=channel_config["channel_names"],
document_sets=slack_bot_config_creation_request.document_sets,
existing_persona_id=None,
).id
slack_bot_config_model = insert_slack_bot_config(
document_sets=slack_bot_config_creation_request.document_sets,
persona_id=persona_id,
channel_config=channel_config,
db_session=db_session,
)
return SlackBotConfig(
id=slack_bot_config_model.id,
document_sets=[
DocumentSet.from_model(document_set)
for document_set in slack_bot_config_model.persona.document_sets
]
if slack_bot_config_model.persona
else [],
persona=(
PersonaSnapshot.from_model(slack_bot_config_model.persona)
if slack_bot_config_model.persona
else None
),
channel_config=slack_bot_config_model.channel_config,
)
@@ -111,20 +123,39 @@ def patch_slack_bot_config(
slack_bot_config_creation_request, slack_bot_config_id, db_session
)
persona_id = None
if slack_bot_config_creation_request.persona_id is not None:
persona_id = slack_bot_config_creation_request.persona_id
elif slack_bot_config_creation_request.document_sets:
existing_slack_bot_config = fetch_slack_bot_config(
db_session=db_session, slack_bot_config_id=slack_bot_config_id
)
if existing_slack_bot_config is None:
raise HTTPException(
status_code=404,
detail="Slack bot config not found",
)
persona_id = create_slack_bot_persona(
db_session=db_session,
channel_names=channel_config["channel_names"],
document_sets=slack_bot_config_creation_request.document_sets,
existing_persona_id=existing_slack_bot_config.persona_id,
).id
slack_bot_config_model = update_slack_bot_config(
slack_bot_config_id=slack_bot_config_id,
document_sets=slack_bot_config_creation_request.document_sets,
persona_id=persona_id,
channel_config=channel_config,
db_session=db_session,
)
return SlackBotConfig(
id=slack_bot_config_model.id,
document_sets=[
DocumentSet.from_model(document_set)
for document_set in slack_bot_config_model.persona.document_sets
]
if slack_bot_config_model.persona
else [],
persona=(
PersonaSnapshot.from_model(slack_bot_config_model.persona)
if slack_bot_config_model.persona
else None
),
channel_config=slack_bot_config_model.channel_config,
)
@@ -149,12 +180,11 @@ def list_slack_bot_configs(
return [
SlackBotConfig(
id=slack_bot_config_model.id,
document_sets=[
DocumentSet.from_model(document_set)
for document_set in slack_bot_config_model.persona.document_sets
]
if slack_bot_config_model.persona
else [],
persona=(
PersonaSnapshot.from_model(slack_bot_config_model.persona)
if slack_bot_config_model.persona
else None
),
channel_config=slack_bot_config_model.channel_config,
)
for slack_bot_config_model in slack_bot_config_models

View File

@@ -6,27 +6,52 @@ import { usePopup } from "@/components/admin/connectors/Popup";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import {
BooleanFormField,
Label,
SectionHeader,
SelectorFormField,
SubLabel,
TextArrayField,
} from "@/components/admin/connectors/Field";
import { createSlackBotConfig, updateSlackBotConfig } from "./lib";
import { Card, Divider } from "@tremor/react";
import {
createSlackBotConfig,
isPersonaASlackBotPersona,
updateSlackBotConfig,
} from "./lib";
import {
Card,
Divider,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
Text,
} from "@tremor/react";
import { useRouter } from "next/navigation";
interface SetCreationPopupProps {
documentSets: DocumentSet[];
existingSlackBotConfig?: SlackBotConfig;
}
import { Persona } from "../personas/interfaces";
import { useState } from "react";
import { BookmarkIcon, RobotIcon } from "@/components/icons/icons";
export const SlackBotCreationForm = ({
documentSets,
personas,
existingSlackBotConfig,
}: SetCreationPopupProps) => {
}: {
documentSets: DocumentSet[];
personas: Persona[];
existingSlackBotConfig?: SlackBotConfig;
}) => {
const isUpdate = existingSlackBotConfig !== undefined;
console.log(existingSlackBotConfig);
const { popup, setPopup } = usePopup();
const router = useRouter();
const existingSlackBotUsesPersona = existingSlackBotConfig?.persona
? !isPersonaASlackBotPersona(existingSlackBotConfig.persona)
: false;
const [usingPersonas, setUsingPersonas] = useState(
existingSlackBotUsesPersona
);
return (
<div className="dark">
<Card>
@@ -47,11 +72,17 @@ export const SlackBotCreationForm = ({
respond_team_member_list:
existingSlackBotConfig?.channel_config
?.respond_team_member_list || ([] as string[]),
document_sets: existingSlackBotConfig
? existingSlackBotConfig.document_sets.map(
(documentSet) => documentSet.id
)
: ([] as number[]),
document_sets:
existingSlackBotConfig && existingSlackBotConfig.persona
? existingSlackBotConfig.persona.document_sets.map(
(documentSet) => documentSet.id
)
: ([] as number[]),
persona_id:
existingSlackBotConfig?.persona &&
!isPersonaASlackBotPersona(existingSlackBotConfig.persona)
? existingSlackBotConfig.persona.id
: null,
}}
validationSchema={Yup.object().shape({
channel_names: Yup.array().of(Yup.string()),
@@ -60,6 +91,7 @@ export const SlackBotCreationForm = ({
respond_tag_only: Yup.boolean().required(),
respond_team_member_list: Yup.array().of(Yup.string()).required(),
document_sets: Yup.array().of(Yup.number()),
persona_id: Yup.number().nullable(),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
@@ -73,6 +105,7 @@ export const SlackBotCreationForm = ({
respond_team_member_list: values.respond_team_member_list.filter(
(teamMemberEmail) => teamMemberEmail !== ""
),
usePersona: usingPersonas,
};
let response;
@@ -102,6 +135,8 @@ export const SlackBotCreationForm = ({
{({ isSubmitting, values }) => (
<Form>
<div className="px-6 pb-6">
<SectionHeader>The Basics</SectionHeader>
<TextArrayField
name="channel_names"
label="Channel Names"
@@ -120,24 +155,24 @@ export const SlackBotCreationForm = ({
}
/>
<Divider />
<SectionHeader>When should DanswerBot respond?</SectionHeader>
<BooleanFormField
name="answer_validity_check_enabled"
label="Hide Non-Answers"
subtext="If set, will only answer questions that the model determines it can answer"
/>
<Divider />
<BooleanFormField
name="questionmark_prefilter_enabled"
label="Only respond to questions"
subtext="If set, will only respond to messages that contain a question mark"
/>
<Divider />
<BooleanFormField
name="respond_tag_only"
label="Respond to @DanswerBot Only"
subtext="If set, DanswerBot will only respond when directly tagged"
/>
<Divider />
<TextArrayField
name="respond_team_member_list"
label="Team Members Emails"
@@ -150,29 +185,67 @@ export const SlackBotCreationForm = ({
values={values}
/>
<Divider />
<FieldArray
name="document_sets"
render={(arrayHelpers: ArrayHelpers) => (
<div>
<div>
<Label>Document Sets</Label>
<SubLabel>
The document sets that DanswerBot should search
through. If left blank, DanswerBot will search through
all documents.
</SubLabel>
</div>
<div className="mb-3 mt-2 flex gap-2 flex-wrap">
{documentSets.map((documentSet) => {
const ind = values.document_sets.indexOf(
documentSet.id
);
let isSelected = ind !== -1;
return (
<div
key={documentSet.id}
className={
`
<div>
<SectionHeader>
[Optional] Data Sources and Prompts
</SectionHeader>
<Text>
Use either a Persona <b>or</b> Document Sets to control how
DanswerBot answers.
</Text>
<div className="text-dark-tremor-content text-sm">
<ul className="list-disc mt-2 ml-4">
<li>
You should use a Persona if you also want to customize
the prompt and retrieval settings.
</li>
<li>
You should use Document Sets if you just want to control
which documents DanswerBot uses as references.
</li>
</ul>
</div>
<Text className="mt-2">
<b>NOTE:</b> whichever tab you are when you submit the form
will be the one that is used. For example, if you are on the
&quot;Personas&quot; tab, then the Persona will be used, even if you
have Document Sets selected.
</Text>
</div>
<TabGroup
index={usingPersonas ? 1 : 0}
onIndexChange={(index) => setUsingPersonas(index === 1)}
>
<TabList className="mt-3 mb-4">
<Tab icon={BookmarkIcon}>Document Sets</Tab>
<Tab icon={RobotIcon}>Personas</Tab>
</TabList>
<TabPanels>
<TabPanel>
<FieldArray
name="document_sets"
render={(arrayHelpers: ArrayHelpers) => (
<div>
<div>
<SubLabel>
The document sets that DanswerBot should search
through. If left blank, DanswerBot will search
through all documents.
</SubLabel>
</div>
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
{documentSets.map((documentSet) => {
const ind = values.document_sets.indexOf(
documentSet.id
);
let isSelected = ind !== -1;
return (
<div
key={documentSet.id}
className={
`
px-3
py-1
rounded-lg
@@ -181,27 +254,50 @@ export const SlackBotCreationForm = ({
w-fit
flex
cursor-pointer ` +
(isSelected
? " bg-gray-600"
: " bg-gray-900 hover:bg-gray-700")
}
onClick={() => {
if (isSelected) {
arrayHelpers.remove(ind);
} else {
arrayHelpers.push(documentSet.id);
}
}}
>
<div className="my-auto">{documentSet.name}</div>
(isSelected
? " bg-gray-600"
: " bg-gray-900 hover:bg-gray-700")
}
onClick={() => {
if (isSelected) {
arrayHelpers.remove(ind);
} else {
arrayHelpers.push(documentSet.id);
}
}}
>
<div className="my-auto">
{documentSet.name}
</div>
</div>
);
})}
</div>
);
</div>
)}
/>
</TabPanel>
<TabPanel>
<SelectorFormField
name="persona_id"
subtext={`
The persona to use when responding to queries. The Default persona acts
as a question-answering assistant and has access to all documents indexed by non-private connectors.
`}
options={personas.map((persona) => {
return {
name: persona.name,
value: persona.id,
};
})}
</div>
</div>
)}
/>
includeDefault={true}
/>
</TabPanel>
</TabPanels>
</TabGroup>
<Divider />
<div className="flex">
<button
type="submit"

View File

@@ -6,14 +6,17 @@ import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import { Text } from "@tremor/react";
import { BackButton } from "@/components/BackButton";
import { Persona } from "../../personas/interfaces";
async function Page({ params }: { params: { id: string } }) {
const tasks = [
fetchSS("/manage/admin/slack-bot/config"),
fetchSS("/manage/document-set"),
fetchSS("/manage/admin/slack-bot/config", undefined, true),
fetchSS("/manage/document-set", undefined, true),
fetchSS("/persona", undefined, true),
];
const [slackBotsResponse, documentSetsResponse] = await Promise.all(tasks);
const [slackBotsResponse, documentSetsResponse, personasResponse] =
await Promise.all(tasks);
if (!slackBotsResponse.ok) {
return (
@@ -47,6 +50,16 @@ async function Page({ params }: { params: { id: string } }) {
}
const documentSets = (await documentSetsResponse.json()) as DocumentSet[];
if (!personasResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch personas - ${await personasResponse.text()}`}
/>
);
}
const personas = (await personasResponse.json()) as Persona[];
return (
<div className="container mx-auto dark">
<BackButton />
@@ -62,6 +75,7 @@ async function Page({ params }: { params: { id: string } }) {
<SlackBotCreationForm
documentSets={documentSets}
personas={personas}
existingSlackBotConfig={slackBotConfig}
/>
</div>

View File

@@ -1,12 +1,15 @@
import { ChannelConfig, SlackBotTokens } from "@/lib/types";
import { Persona } from "../personas/interfaces";
interface SlackBotConfigCreationRequest {
document_sets: number[];
persona_id: number | null;
channel_names: string[];
answer_validity_check_enabled: boolean;
questionmark_prefilter_enabled: boolean;
respond_tag_only: boolean;
respond_team_member_list: string[];
usePersona: boolean;
}
const buildFiltersFromCreationRequest = (
@@ -29,8 +32,10 @@ const buildRequestBodyFromCreationRequest = (
channel_names: creationRequest.channel_names,
respond_tag_only: creationRequest.respond_tag_only,
respond_team_member_list: creationRequest.respond_team_member_list,
document_sets: creationRequest.document_sets,
answer_filters: buildFiltersFromCreationRequest(creationRequest),
...(creationRequest.usePersona
? { persona_id: creationRequest.persona_id }
: { document_sets: creationRequest.document_sets }),
});
};
@@ -77,3 +82,7 @@ export const setSlackBotTokens = async (slackBotTokens: SlackBotTokens) => {
body: JSON.stringify(slackBotTokens),
});
};
export function isPersonaASlackBotPersona(persona: Persona) {
return persona.name.startsWith("__slack_bot_persona__");
}

View File

@@ -6,9 +6,12 @@ import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSet } from "@/lib/types";
import { BackButton } from "@/components/BackButton";
import { Text } from "@tremor/react";
import { Persona } from "../../personas/interfaces";
async function Page() {
const documentSetsResponse = await fetchSS("/manage/document-set");
const tasks = [fetchSS("/manage/document-set"), fetchSS("/persona")];
const [documentSetsResponse, personasResponse] = await Promise.all(tasks);
if (!documentSetsResponse.ok) {
return (
<ErrorCallout
@@ -19,6 +22,16 @@ async function Page() {
}
const documentSets = (await documentSetsResponse.json()) as DocumentSet[];
if (!personasResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch personas - ${await personasResponse.text()}`}
/>
);
}
const personas = (await personasResponse.json()) as Persona[];
return (
<div className="container mx-auto dark">
<BackButton />
@@ -32,7 +45,7 @@ async function Page() {
DanswerBot behaves in the specified channels.
</Text>
<SlackBotCreationForm documentSets={documentSets} />
<SlackBotCreationForm documentSets={documentSets} personas={personas} />
</div>
);
}

View File

@@ -2,17 +2,26 @@
import { ThreeDotsLoader } from "@/components/Loading";
import { PageSelector } from "@/components/PageSelector";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { CPUIcon, EditIcon, TrashIcon } from "@/components/icons/icons";
import { SlackBotConfig } from "@/lib/types";
import { useState } from "react";
import { useSlackBotConfigs, useSlackBotTokens } from "./hooks";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { deleteSlackBotConfig } from "./lib";
import { deleteSlackBotConfig, isPersonaASlackBotPersona } from "./lib";
import { SlackBotTokensForm } from "./SlackBotTokensForm";
import { AdminPageTitle } from "@/components/admin/Title";
import { Button, Text, Title } from "@tremor/react";
import { FiChevronDown, FiChevronUp } from "react-icons/fi";
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Text,
Title,
} from "@tremor/react";
import { FiArrowUpRight, FiChevronDown, FiChevronUp } from "react-icons/fi";
import Link from "next/link";
const numToDisplay = 50;
@@ -41,119 +50,93 @@ const SlackBotConfigsTable = ({
return (
<div>
<BasicTable
columns={[
{
header: "Channels",
key: "channels",
},
{
header: "Document Sets",
key: "document_sets",
},
{
header: "Team Members",
key: "team_members",
},
{
header: "Hide Non-Answers",
key: "answer_validity_check_enabled",
},
{
header: "Questions Only",
key: "question_mark_only",
},
{
header: "Tags Only",
key: "respond_tag_only",
},
{
header: "Delete",
key: "delete",
width: "50px",
},
]}
data={slackBotConfigs
.slice((page - 1) * numToDisplay, page * numToDisplay)
.map((slackBotConfig) => {
return {
channels: (
<div className="flex gap-x-2">
<Link
className="cursor-pointer my-auto"
href={`/admin/bot/${slackBotConfig.id}`}
>
<EditIcon />
</Link>
<div className="my-auto">
{slackBotConfig.channel_config.channel_names
.map((channel_name) => `#${channel_name}`)
.join(", ")}
</div>
</div>
),
document_sets: (
<div>
{slackBotConfig.document_sets
.map((documentSet) => documentSet.name)
.join(", ")}
</div>
),
team_members: (
<div>
{(
slackBotConfig.channel_config.respond_team_member_list || []
).join(", ")}
</div>
),
answer_validity_check_enabled: (
slackBotConfig.channel_config.answer_filters || []
).includes("well_answered_postfilter") ? (
<div className="text-gray-300">Yes</div>
) : (
<div className="text-gray-300">No</div>
),
question_mark_only: (
slackBotConfig.channel_config.answer_filters || []
).includes("questionmark_prefilter") ? (
<div className="text-gray-300">Yes</div>
) : (
<div className="text-gray-300">No</div>
),
respond_tag_only:
slackBotConfig.channel_config.respond_tag_only || false ? (
<div className="text-gray-300">Yes</div>
) : (
<div className="text-gray-300">No</div>
),
delete: (
<div
className="cursor-pointer"
onClick={async () => {
const response = await deleteSlackBotConfig(
slackBotConfig.id
);
if (response.ok) {
setPopup({
message: `Slack bot config "${slackBotConfig.id}" deleted`,
type: "success",
});
} else {
const errorMsg = await response.text();
setPopup({
message: `Failed to delete Slack bot config - ${errorMsg}`,
type: "error",
});
}
refresh();
}}
>
<TrashIcon />
</div>
),
};
})}
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Channels</TableHeaderCell>
<TableHeaderCell>Persona</TableHeaderCell>
<TableHeaderCell>Document Sets</TableHeaderCell>
<TableHeaderCell>Delete</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{slackBotConfigs
.slice(numToDisplay * (page - 1), numToDisplay * page)
.map((slackBotConfig) => {
return (
<TableRow key={slackBotConfig.id}>
<TableCell>
<div className="flex gap-x-2">
<Link
className="cursor-pointer my-auto"
href={`/admin/bot/${slackBotConfig.id}`}
>
<EditIcon />
</Link>
<div className="my-auto">
{slackBotConfig.channel_config.channel_names
.map((channel_name) => `#${channel_name}`)
.join(", ")}
</div>
</div>
</TableCell>
<TableCell>
{slackBotConfig.persona &&
!isPersonaASlackBotPersona(slackBotConfig.persona) ? (
<Link
href={`/admin/personas/${slackBotConfig.persona.id}`}
className="text-blue-500 flex"
>
<FiArrowUpRight className="my-auto mr-1" />
{slackBotConfig.persona.name}
</Link>
) : (
"-"
)}
</TableCell>
<TableCell>
{" "}
<div>
{slackBotConfig.persona &&
slackBotConfig.persona.document_sets.length > 0
? slackBotConfig.persona.document_sets
.map((documentSet) => documentSet.name)
.join(", ")
: "-"}
</div>
</TableCell>
<TableCell>
{" "}
<div
className="cursor-pointer"
onClick={async () => {
const response = await deleteSlackBotConfig(
slackBotConfig.id
);
if (response.ok) {
setPopup({
message: `Slack bot config "${slackBotConfig.id}" deleted`,
type: "success",
});
} else {
const errorMsg = await response.text();
setPopup({
message: `Failed to delete Slack bot config - ${errorMsg}`,
type: "error",
});
}
refresh();
}}
>
<TrashIcon />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="mt-3 flex">
<div className="mx-auto">
<PageSelector

View File

@@ -72,30 +72,36 @@ export default function Web() {
inputType="load_state"
formBody={
<>
<TextFormField name="base_url" label="URL to Index:" />
<SelectorFormField
name="web_connector_type"
label="Scrape Method:"
options={[
{
name: "Recursive",
value: "recursive",
description:
"Recursively index all pages that share the same base URL.",
},
{
name: "Single Page",
value: "single",
description: "Index only the specified page.",
},
{
name: "Sitemap",
value: "sitemap",
description:
"Assumes the URL to Index points to a Sitemap. Will try and index all pages that are a mentioned in the sitemap.",
},
]}
<TextFormField
name="base_url"
label="URL to Index:"
autoCompleteDisabled={false}
/>
<div className="w-full">
<SelectorFormField
name="web_connector_type"
label="Scrape Method:"
options={[
{
name: "Recursive",
value: "recursive",
description:
"Recursively index all pages that share the same base URL.",
},
{
name: "Single Page",
value: "single",
description: "Index only the specified page.",
},
{
name: "Sitemap",
value: "sitemap",
description:
"Assumes the URL to Index points to a Sitemap. Will try and index all pages that are a mentioned in the sitemap.",
},
]}
/>
</div>
</>
}
validationSchema={Yup.object().shape({

View File

@@ -1,20 +1,28 @@
import { ChangeEvent, FC, useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "./icons/icons";
import { FiCheck, FiChevronDown } from "react-icons/fi";
import { FaRobot } from "react-icons/fa";
export interface Option {
export interface Option<T> {
name: string;
value: string;
value: T;
description?: string;
metadata?: { [key: string]: any };
}
interface DropdownProps {
options: Option[];
export type StringOrNumberOption = Option<string | number>;
interface DropdownProps<T> {
options: Option<T>[];
selected: string;
onSelect: (selected: Option) => void;
onSelect: (selected: Option<T> | null) => void;
}
export const Dropdown = ({ options, selected, onSelect }: DropdownProps) => {
export const Dropdown = ({
options,
selected,
onSelect,
}: DropdownProps<string | number>) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -22,7 +30,7 @@ export const Dropdown = ({ options, selected, onSelect }: DropdownProps) => {
(option) => option.value === selected
)?.name;
const handleSelect = (option: Option) => {
const handleSelect = (option: StringOrNumberOption) => {
onSelect(option);
setIsOpen(false);
};
@@ -106,15 +114,15 @@ export const Dropdown = ({ options, selected, onSelect }: DropdownProps) => {
);
};
const StandardDropdownOption = ({
function StandardDropdownOption<T>({
index,
option,
handleSelect,
}: {
index: number;
option: Option;
handleSelect: (option: Option) => void;
}) => {
option: Option<T>;
handleSelect: (option: Option<T>) => void;
}) {
return (
<button
onClick={() => handleSelect(option)}
@@ -131,24 +139,22 @@ const StandardDropdownOption = ({
)}
</button>
);
};
interface MultiSelectDropdownProps {
options: Option[];
onSelect: (selected: Option) => void;
itemComponent?: FC<{ option: Option }>;
}
export const SearchMultiSelectDropdown: FC<MultiSelectDropdownProps> = ({
export function SearchMultiSelectDropdown({
options,
onSelect,
itemComponent,
}) => {
}: {
options: StringOrNumberOption[];
onSelect: (selected: StringOrNumberOption) => void;
itemComponent?: FC<{ option: StringOrNumberOption }>;
}) {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
const handleSelect = (option: Option) => {
const handleSelect = (option: StringOrNumberOption) => {
onSelect(option);
setIsOpen(false);
setSearchTerm(""); // Clear search term after selection
@@ -273,7 +279,7 @@ export const SearchMultiSelectDropdown: FC<MultiSelectDropdownProps> = ({
)}
</div>
);
};
}
export const CustomDropdown = ({
children,
@@ -316,3 +322,134 @@ export const CustomDropdown = ({
</div>
);
};
function DefaultDropdownElement({
id,
name,
description,
onSelect,
isSelected,
isFinal,
}: {
id: string | number | null;
name: string;
description?: string;
onSelect: (value: string | number | null) => void;
isSelected: boolean;
isFinal: boolean;
}) {
console.log(isFinal);
return (
<div
className={`
flex
px-3
text-sm
text-gray-200
py-2.5
select-none
cursor-pointer
${isFinal ? "" : "border-b border-gray-800"}
${
isSelected
? "bg-dark-tremor-background-muted"
: "hover:bg-dark-tremor-background-muted "
}
`}
onClick={() => {
onSelect(id);
}}
>
<div>
{name}
{description && (
<div className="text-xs text-dark-tremor-content">{description}</div>
)}
</div>
{isSelected && (
<div className="ml-auto mr-1 my-auto">
<FiCheck />
</div>
)}
</div>
);
}
export function DefaultDropdown({
options,
selected,
onSelect,
includeDefault = false,
}: {
options: StringOrNumberOption[];
selected: string | null;
onSelect: (value: string | number | null) => void;
includeDefault?: boolean;
}) {
const selectedOption = options.find((option) => option.value === selected);
return (
<CustomDropdown
dropdown={
<div
className={`
border
border-gray-800
rounded-lg
flex
flex-col
max-h-96
overflow-y-auto
overscroll-contain`}
>
{includeDefault && (
<DefaultDropdownElement
key={-1}
id={null}
name="Default"
onSelect={() => {
onSelect(null);
}}
isSelected={selected === null}
isFinal={false}
/>
)}
{options.map((option, ind) => {
const isSelected = option.value === selected;
return (
<DefaultDropdownElement
key={option.value}
id={option.value}
name={option.name}
description={option.description}
onSelect={onSelect}
isSelected={isSelected}
isFinal={ind === options.length - 1}
/>
);
})}
</div>
}
>
<div
className={`
flex
text-sm
text-gray-400
px-3
py-1.5
rounded-lg
border
border-gray-800
cursor-pointer
hover:bg-dark-tremor-background-muted`}
>
<p className="text-gray-200 line-clamp-1">
{selectedOption?.name ||
(includeDefault ? "Default" : "Select an option...")}
</p>
<FiChevronDown className="my-auto ml-auto" />
</div>
</CustomDropdown>
);
}

View File

@@ -9,9 +9,17 @@ import {
} from "formik";
import * as Yup from "yup";
import { FormBodyBuilder } from "./types";
import { Dropdown, Option } from "@/components/Dropdown";
import { DefaultDropdown, StringOrNumberOption } from "@/components/Dropdown";
import { FiPlus, FiX } from "react-icons/fi";
export function SectionHeader({
children,
}: {
children: string | JSX.Element;
}) {
return <div className="mb-4 font-bold text-lg">{children}</div>;
}
export function Label({ children }: { children: string | JSX.Element }) {
return (
<div className="block font-medium text-base text-gray-200">{children}</div>
@@ -212,9 +220,10 @@ export function TextArrayFieldBuilder<T extends Yup.AnyObject>(
interface SelectorFormFieldProps {
name: string;
label: string;
options: Option[];
label?: string;
options: StringOrNumberOption[];
subtext?: string;
includeDefault?: boolean;
}
export function SelectorFormField({
@@ -222,24 +231,24 @@ export function SelectorFormField({
label,
options,
subtext,
includeDefault = false,
}: SelectorFormFieldProps) {
const [field] = useField<string>(name);
const { setFieldValue } = useFormikContext();
return (
<div className="mb-4">
<label className="flex mb-2">
<div>
{label}
{subtext && <p className="text-xs">{subtext}</p>}
</div>
</label>
<div className="mb-4 dark">
{label && <Label>{label}</Label>}
{subtext && <SubLabel>{subtext}</SubLabel>}
<Dropdown
options={options}
selected={field.value}
onSelect={(selected) => setFieldValue(name, selected.value)}
/>
<div className="mt-2">
<DefaultDropdown
options={options}
selected={field.value}
onSelect={(selected) => setFieldValue(name, selected)}
includeDefault={includeDefault}
/>
</div>
<ErrorMessage
name={name}

View File

@@ -1,3 +1,5 @@
import { Persona } from "@/app/admin/personas/interfaces";
export interface User {
id: string;
email: string;
@@ -301,7 +303,7 @@ export interface ChannelConfig {
export interface SlackBotConfig {
id: number;
document_sets: DocumentSet[];
persona: Persona | null;
channel_config: ChannelConfig;
}

View File

@@ -8,7 +8,11 @@ export function buildUrl(path: string) {
return `${INTERNAL_URL}/${path}`;
}
export function fetchSS(url: string, options?: RequestInit) {
export function fetchSS(
url: string,
options?: RequestInit,
addRandomTimestamp: boolean = false
) {
const init = options || {
credentials: "include",
cache: "no-store",
@@ -20,5 +24,11 @@ export function fetchSS(url: string, options?: RequestInit) {
},
};
// add a random timestamp to force NextJS to refetch rather than
// used cached data
if (addRandomTimestamp) {
const timestamp = Date.now();
url = `${url}?u=${timestamp}`;
}
return fetch(buildUrl(url), init);
}