Slack bot management dashboard (#483)

This commit is contained in:
Chris Weaver
2023-09-26 14:03:27 -07:00
committed by GitHub
parent 0c58c8d6cb
commit d41d844116
24 changed files with 1401 additions and 49 deletions

View File

@@ -0,0 +1,188 @@
import { ArrayHelpers, FieldArray, Form, Formik } from "formik";
import * as Yup from "yup";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import {
BooleanFormField,
TextArrayField,
} from "@/components/admin/connectors/Field";
import { createSlackBotConfig, updateSlackBotConfig } from "./lib";
import { channel } from "diagnostics_channel";
interface SetCreationPopupProps {
onClose: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
documentSets: DocumentSet<any, any>[];
existingSlackBotConfig?: SlackBotConfig;
}
export const SlackBotCreationForm = ({
onClose,
setPopup,
documentSets,
existingSlackBotConfig,
}: SetCreationPopupProps) => {
const isUpdate = existingSlackBotConfig !== undefined;
return (
<div>
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-gray-800 p-6 rounded border border-gray-700 shadow-lg relative w-1/2 text-sm"
onClick={(event) => event.stopPropagation()}
>
<Formik
initialValues={{
channel_names: existingSlackBotConfig
? existingSlackBotConfig.channel_config.channel_names
: ([] as string[]),
answer_validity_check_enabled:
existingSlackBotConfig?.channel_config
?.answer_validity_check_enabled || false,
document_sets: existingSlackBotConfig
? existingSlackBotConfig.document_sets.map(
(documentSet) => documentSet.id
)
: ([] as number[]),
}}
validationSchema={Yup.object().shape({
channel_names: Yup.array().of(Yup.string()),
answer_validity_check_enabled: Yup.boolean().required(),
document_sets: Yup.array().of(Yup.number()),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
// remove empty channel names
const cleanedValues = {
...values,
channel_names: values.channel_names.filter(
(channelName) => channelName !== ""
),
};
let response;
if (isUpdate) {
response = await updateSlackBotConfig(
existingSlackBotConfig.id,
cleanedValues
);
} else {
response = await createSlackBotConfig(cleanedValues);
}
formikHelpers.setSubmitting(false);
if (response.ok) {
setPopup({
message: isUpdate
? "Successfully updated DanswerBot config!"
: "Successfully created DanswerBot config!",
type: "success",
});
onClose();
} else {
const errorMsg = (await response.json()).detail;
setPopup({
message: isUpdate
? `Error updating DanswerBot config - ${errorMsg}`
: `Error creating DanswerBot config - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting, values }) => (
<Form>
<h2 className="text-lg font-bold mb-3">
{isUpdate
? "Update a DanswerBot Config"
: "Create a new DanswerBot Config"}
</h2>
<TextArrayField
name="channel_names"
label="Channel Names:"
values={values}
subtext="The names of the Slack channels you want DanswerBot to assist in. For example, '#ask-danswer'."
/>
<div className="border-t border-gray-700 py-2" />
<BooleanFormField
name="answer_validity_check_enabled"
label="Hide Non-Answers"
subtext="If set, will only answer questions that the model determines it can answer"
/>
<div className="border-t border-gray-700 py-2" />
<FieldArray
name="document_sets"
render={(arrayHelpers: ArrayHelpers) => (
<div>
<div>
Document Sets:
<br />
<div className="text-xs">
The document sets that DanswerBot should search
through. If left blank, DanswerBot will search through
all documents.
</div>
</div>
<div className="mb-3 mt-2 flex gap-2 flex-wrap">
{documentSets.map((documentSet) => {
const ind = values.document_sets.indexOf(
documentSet.id
);
let isSelected = ind !== -1;
return (
<div
key={documentSet.id}
className={
`
px-3
py-1
rounded-lg
border
border-gray-700
w-fit
flex
cursor-pointer ` +
(isSelected
? " bg-gray-600"
: " bg-gray-900 hover:bg-gray-700")
}
onClick={() => {
if (isSelected) {
arrayHelpers.remove(ind);
} else {
arrayHelpers.push(documentSet.id);
}
}}
>
<div className="my-auto">{documentSet.name}</div>
</div>
);
})}
</div>
</div>
)}
/>
<div className="flex">
<button
type="submit"
disabled={isSubmitting}
className={
"bg-slate-500 hover:bg-slate-700 text-white " +
"font-bold py-2 px-4 rounded focus:outline-none " +
"focus:shadow-outline w-full max-w-sm mx-auto"
}
>
{isUpdate ? "Update!" : "Create!"}
</button>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { SlackBotTokens } from "@/lib/types";
import {
TextArrayField,
TextFormField,
} from "@/components/admin/connectors/Field";
import {
createSlackBotConfig,
setSlackBotTokens,
updateSlackBotConfig,
} from "./lib";
interface SlackBotTokensFormProps {
onClose: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
existingTokens?: SlackBotTokens;
}
export const SlackBotTokensForm = ({
onClose,
setPopup,
existingTokens,
}: SlackBotTokensFormProps) => {
return (
<div>
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-gray-800 p-6 rounded border border-gray-700 shadow-lg relative w-1/2 text-sm"
onClick={(event) => event.stopPropagation()}
>
<Formik
initialValues={existingTokens || { app_token: "", bot_token: "" }}
validationSchema={Yup.object().shape({
channel_names: Yup.array().of(Yup.string().required()),
document_sets: Yup.array().of(Yup.number()),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
const response = await setSlackBotTokens(values);
formikHelpers.setSubmitting(false);
if (response.ok) {
setPopup({
message: "Successfully set Slack tokens!",
type: "success",
});
onClose();
} else {
const errorMsg = await response.text();
setPopup({
message: `Error setting Slack tokens - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting }) => (
<Form>
<h2 className="text-lg font-bold mb-3">Set Slack Bot Tokens</h2>
<TextFormField
name="bot_token"
label="Slack Bot Token"
type="password"
/>
<TextFormField
name="app_token"
label="Slack App Token"
type="password"
/>
<div className="flex">
<button
type="submit"
disabled={isSubmitting}
className={
"bg-slate-500 hover:bg-slate-700 text-white " +
"font-bold py-2 px-4 rounded focus:outline-none " +
"focus:shadow-outline w-full max-w-sm mx-auto"
}
>
Set Tokens
</button>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { errorHandlingFetcher } from "@/lib/fetcher";
import { SlackBotConfig, SlackBotTokens } from "@/lib/types";
import useSWR, { mutate } from "swr";
export const useSlackBotConfigs = () => {
const url = "/api/manage/admin/slack-bot/config";
const swrResponse = useSWR<SlackBotConfig[]>(url, errorHandlingFetcher);
return {
...swrResponse,
refreshSlackBotConfigs: () => mutate(url),
};
};
export const useSlackBotTokens = () => {
const url = "/api/manage/admin/slack-bot/tokens";
const swrResponse = useSWR<SlackBotTokens>(url, errorHandlingFetcher);
return {
...swrResponse,
refreshSlackBotTokens: () => mutate(url),
};
};

View File

@@ -0,0 +1,51 @@
import { ChannelConfig, SlackBotTokens } from "@/lib/types";
interface SlackBotConfigCreationRequest {
document_sets: number[];
channel_names: string[];
answer_validity_check_enabled: boolean;
}
export const createSlackBotConfig = async (
creationRequest: SlackBotConfigCreationRequest
) => {
return fetch("/api/manage/admin/slack-bot/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(creationRequest),
});
};
export const updateSlackBotConfig = async (
id: number,
creationRequest: SlackBotConfigCreationRequest
) => {
return fetch(`/api/manage/admin/slack-bot/config/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(creationRequest),
});
};
export const deleteSlackBotConfig = async (id: number) => {
return fetch(`/api/manage/admin/slack-bot/config/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
};
export const setSlackBotTokens = async (slackBotTokens: SlackBotTokens) => {
return fetch(`/api/manage/admin/slack-bot/tokens`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(slackBotTokens),
});
};

View File

@@ -0,0 +1,300 @@
"use client";
import { Button } from "@/components/Button";
import { ThreeDotsLoader } from "@/components/Loading";
import { PageSelector } from "@/components/PageSelector";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import {
BookmarkIcon,
CPUIcon,
EditIcon,
TrashIcon,
} from "@/components/icons/icons";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import { useState } from "react";
import { useSlackBotConfigs, useSlackBotTokens } from "./hooks";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { SlackBotCreationForm } from "./SlackBotConfigCreationForm";
import { deleteSlackBotConfig } from "./lib";
import { SlackBotTokensForm } from "./SlackBotTokensForm";
import { useDocumentSets } from "../documents/sets/hooks";
const numToDisplay = 50;
const EditRow = ({
existingSlackBotConfig,
setPopup,
documentSets,
refreshSlackBotConfigs,
}: {
existingSlackBotConfig: SlackBotConfig;
setPopup: (popupSpec: PopupSpec | null) => void;
documentSets: DocumentSet<any, any>[];
refreshSlackBotConfigs: () => void;
}) => {
const [isEditPopupOpen, setEditPopupOpen] = useState(false);
return (
<>
{isEditPopupOpen && (
<SlackBotCreationForm
onClose={() => {
setEditPopupOpen(false);
refreshSlackBotConfigs();
}}
setPopup={setPopup}
documentSets={documentSets}
existingSlackBotConfig={existingSlackBotConfig}
/>
)}
<div
className="cursor-pointer my-auto"
onClick={() => setEditPopupOpen(true)}
>
<EditIcon />
</div>
</>
);
};
interface DocumentFeedbackTableProps {
slackBotConfigs: SlackBotConfig[];
documentSets: DocumentSet<any, any>[];
refresh: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
}
const SlackBotConfigsTable = ({
slackBotConfigs,
documentSets,
refresh,
setPopup,
}: DocumentFeedbackTableProps) => {
const [page, setPage] = useState(1);
// sort by name for consistent ordering
slackBotConfigs.sort((a, b) => {
if (a.id < b.id) {
return -1;
} else if (a.id > b.id) {
return 1;
} else {
return 0;
}
});
return (
<div>
<BasicTable
columns={[
{
header: "Channels",
key: "channels",
},
{
header: "Document Sets",
key: "document_sets",
},
{
header: "Hide Non-Answers",
key: "answer_validity_check_enabled",
},
{
header: "Delete",
key: "delete",
width: "50px",
},
]}
data={slackBotConfigs
.slice((page - 1) * numToDisplay, page * numToDisplay)
.map((slackBotConfig) => {
return {
channels: (
<div className="flex gap-x-2">
<EditRow
existingSlackBotConfig={slackBotConfig}
setPopup={setPopup}
refreshSlackBotConfigs={refresh}
documentSets={documentSets}
/>
<div className="my-auto">
{slackBotConfig.channel_config.channel_names
.map((channel_name) => `#${channel_name}`)
.join(", ")}
</div>
</div>
),
document_sets: (
<div>
{slackBotConfig.document_sets
.map((documentSet) => documentSet.name)
.join(", ")}
</div>
),
answer_validity_check_enabled: slackBotConfig.channel_config
.answer_validity_check_enabled ? (
<div className="text-gray-300">Yes</div>
) : (
<div className="text-gray-300">No</div>
),
delete: (
<div
className="cursor-pointer"
onClick={async () => {
const response = await deleteSlackBotConfig(
slackBotConfig.id
);
if (response.ok) {
setPopup({
message: `Slack bot config "${slackBotConfig.id}" deleted`,
type: "success",
});
} else {
const errorMsg = await response.text();
setPopup({
message: `Failed to delete Slack bot config - ${errorMsg}`,
type: "error",
});
}
refresh();
}}
>
<TrashIcon />
</div>
),
};
})}
/>
<div className="mt-3 flex">
<div className="mx-auto">
<PageSelector
totalPages={Math.ceil(slackBotConfigs.length / numToDisplay)}
currentPage={page}
onPageChange={(newPage) => setPage(newPage)}
/>
</div>
</div>
</div>
);
};
const Main = () => {
const [slackBotConfigModalIsOpen, setSlackBotConfigModalIsOpen] =
useState(false);
const [slackBotTokensModalIsOpen, setSlackBotTokensModalIsOpen] =
useState(false);
const { popup, setPopup } = usePopup();
const {
data: slackBotConfigs,
isLoading: isSlackBotConfigsLoading,
error: slackBotConfigsError,
refreshSlackBotConfigs,
} = useSlackBotConfigs();
const {
data: documentSets,
isLoading: isDocumentSetsLoading,
error: documentSetsError,
} = useDocumentSets();
const { data: slackBotTokens, refreshSlackBotTokens } = useSlackBotTokens();
if (isSlackBotConfigsLoading || isDocumentSetsLoading) {
return <ThreeDotsLoader />;
}
if (slackBotConfigsError || !slackBotConfigs) {
return <div>Error: {slackBotConfigsError}</div>;
}
if (documentSetsError || !documentSets) {
return <div>Error: {documentSetsError}</div>;
}
return (
<div className="mb-8">
{popup}
<h2 className="text-lg font-bold mb-2">Step 1: Configure Slack Tokens</h2>
{!slackBotTokens ? (
<SlackBotTokensForm
onClose={() => refreshSlackBotTokens()}
setPopup={setPopup}
/>
) : (
<>
<div className="text-sm italic">Tokens saved!</div>
<Button
onClick={() => setSlackBotTokensModalIsOpen(true)}
className="mt-2"
>
Edit Tokens
</Button>
{slackBotTokensModalIsOpen && (
<SlackBotTokensForm
onClose={() => {
refreshSlackBotTokens();
setSlackBotTokensModalIsOpen(false);
}}
setPopup={setPopup}
existingTokens={slackBotTokens}
/>
)}
</>
)}
{slackBotTokens && (
<>
<h2 className="text-lg font-bold mb-2 mt-4">
Step 2: Setup DanswerBot
</h2>
<div className="text-sm mb-3">
Configure Danswer to automatically answer questions in Slack
channels.
</div>
<div className="mb-2"></div>
<div className="flex mb-3">
<Button
className="ml-2 my-auto"
onClick={() => setSlackBotConfigModalIsOpen(true)}
>
New Slack Bot
</Button>
</div>
<SlackBotConfigsTable
slackBotConfigs={slackBotConfigs}
documentSets={documentSets}
refresh={refreshSlackBotConfigs}
setPopup={setPopup}
/>
{slackBotConfigModalIsOpen && (
<SlackBotCreationForm
documentSets={documentSets}
onClose={() => {
refreshSlackBotConfigs();
setSlackBotConfigModalIsOpen(false);
}}
setPopup={setPopup}
/>
)}
</>
)}
</div>
);
};
const Page = () => {
return (
<div>
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<CPUIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Slack Bot Configuration</h1>
</div>
<Main />
</div>
);
};
export default Page;

View File

@@ -1,10 +1,13 @@
import { fetcher } from "@/lib/fetcher";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { DocumentSet } from "@/lib/types";
import useSWR, { mutate } from "swr";
export const useDocumentSets = () => {
const url = "/api/manage/document-set";
const swrResponse = useSWR<DocumentSet<any, any>[]>(url, fetcher);
const swrResponse = useSWR<DocumentSet<any, any>[]>(
url,
errorHandlingFetcher
);
return {
...swrResponse,

View File

@@ -20,6 +20,7 @@ import {
UsersIcon,
ThumbsUpIcon,
BookmarkIcon,
CPUIcon,
} from "@/components/icons/icons";
import { DISABLE_AUTH } from "@/lib/constants";
import { getCurrentUserSS } from "@/lib/userSS";
@@ -44,7 +45,7 @@ export default async function AdminLayout({
return (
<div>
<Header user={user} />
<div className="bg-gray-900 pt-8 flex">
<div className="bg-gray-900 pt-8 pb-8 flex">
<Sidebar
title="Connector"
collections={[
@@ -244,6 +245,20 @@ export default async function AdminLayout({
},
],
},
{
name: "Bots",
items: [
{
name: (
<div className="flex">
<CPUIcon size={18} />
<div className="ml-1">Slack Bot</div>
</div>
),
link: "/admin/bot",
},
],
},
]}
/>
<div className="px-12 min-h-screen bg-gray-900 text-gray-100 w-full">

View File

@@ -87,20 +87,22 @@ export const BooleanFormField = ({
);
};
interface TextArrayFieldProps {
interface TextArrayFieldProps<T extends Yup.AnyObject> {
name: string;
label: string;
values: T;
subtext?: string;
type?: string;
}
export function TextArrayFieldBuilder<T extends Yup.AnyObject>({
export function TextArrayField<T extends Yup.AnyObject>({
name,
label,
values,
subtext,
type = "text",
}: TextArrayFieldProps): FormBodyBuilder<T> {
const TextArrayField: FormBodyBuilder<T> = (values) => (
type,
}: TextArrayFieldProps<T>) {
return (
<div className="mb-4">
<label htmlFor={name} className="block">
{label}
@@ -153,5 +155,20 @@ export function TextArrayFieldBuilder<T extends Yup.AnyObject>({
/>
</div>
);
return TextArrayField;
}
interface TextArrayFieldBuilderProps<T extends Yup.AnyObject> {
name: string;
label: string;
subtext?: string;
type?: string;
}
export function TextArrayFieldBuilder<T extends Yup.AnyObject>(
props: TextArrayFieldBuilderProps<T>
): FormBodyBuilder<T> {
const _TextArrayField: FormBodyBuilder<T> = (values) => (
<TextArrayField {...props} values={values} />
);
return _TextArrayField;
}

View File

@@ -32,6 +32,7 @@ import {
FiZoomIn,
FiCopy,
FiBookmark,
FiCpu,
} from "react-icons/fi";
import { SiBookstack } from "react-icons/si";
import Image from "next/image";
@@ -251,6 +252,13 @@ export const BookmarkIcon = ({
return <FiBookmark size={size} className={className} />;
};
export const CPUIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <FiCpu size={size} className={className} />;
};
//
// COMPANY LOGOS
//

View File

@@ -1 +1,25 @@
export const fetcher = (url: string) => fetch(url).then((res) => res.json());
class FetchError extends Error {
status: number;
info: any;
constructor(message: string, status: number, info: any) {
super(message);
this.status = status;
this.info = info;
}
}
export const errorHandlingFetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new FetchError(
"An error occurred while fetching the data.",
res.status,
await res.json()
);
throw error;
}
return res.json();
};

View File

@@ -228,3 +228,21 @@ export interface DocumentSet<ConnectorType, CredentialType> {
cc_pair_descriptors: CCPairDescriptor<ConnectorType, CredentialType>[];
is_up_to_date: boolean;
}
// SLACK BOT CONFIGS
export interface ChannelConfig {
channel_names: string[];
answer_validity_check_enabled?: boolean;
team_members?: string[];
}
export interface SlackBotConfig {
id: number;
document_sets: DocumentSet<any, any>[];
channel_config: ChannelConfig;
}
export interface SlackBotTokens {
bot_token: string;
app_token: string;
}