From a4869b727d47acdc7b37ee8a9fa0bcc5818acd83 Mon Sep 17 00:00:00 2001 From: Weves Date: Sun, 31 Mar 2024 21:42:30 -0700 Subject: [PATCH] Add ability to control available pages --- backend/danswer/main.py | 4 + backend/danswer/server/settings/api.py | 30 ++++ backend/danswer/server/settings/models.py | 36 +++++ backend/danswer/server/settings/store.py | 23 +++ web/next.config.js | 8 +- web/src/app/admin/settings/SettingsForm.tsx | 149 ++++++++++++++++++++ web/src/app/admin/settings/interfaces.ts | 5 + web/src/app/admin/settings/page.tsx | 33 +++++ web/src/app/chat/ChatPage.tsx | 5 +- web/src/app/chat/page.tsx | 12 +- web/src/app/chat/shared/[chatId]/page.tsx | 18 +-- web/src/app/page.tsx | 16 +++ web/src/app/search/page.tsx | 11 +- web/src/components/Dropdown.tsx | 103 -------------- web/src/components/Header.tsx | 57 +++++--- web/src/components/admin/Layout.tsx | 31 +++- web/src/lib/settings.ts | 10 ++ 17 files changed, 399 insertions(+), 152 deletions(-) create mode 100644 backend/danswer/server/settings/api.py create mode 100644 backend/danswer/server/settings/models.py create mode 100644 backend/danswer/server/settings/store.py create mode 100644 web/src/app/admin/settings/SettingsForm.tsx create mode 100644 web/src/app/admin/settings/interfaces.ts create mode 100644 web/src/app/admin/settings/page.tsx create mode 100644 web/src/app/page.tsx create mode 100644 web/src/lib/settings.ts diff --git a/backend/danswer/main.py b/backend/danswer/main.py index ad4584774..90abab737 100644 --- a/backend/danswer/main.py +++ b/backend/danswer/main.py @@ -76,6 +76,8 @@ from danswer.server.query_and_chat.query_backend import ( admin_router as admin_query_router, ) from danswer.server.query_and_chat.query_backend import basic_router as query_router +from danswer.server.settings.api import admin_router as settings_admin_router +from danswer.server.settings.api import basic_router as settings_router from danswer.utils.logger import setup_logger from danswer.utils.telemetry import optional_telemetry from danswer.utils.telemetry import RecordType @@ -279,6 +281,8 @@ def get_application() -> FastAPI: include_router_with_global_prefix_prepended(application, state_router) include_router_with_global_prefix_prepended(application, danswer_api_router) include_router_with_global_prefix_prepended(application, gpts_router) + include_router_with_global_prefix_prepended(application, settings_router) + include_router_with_global_prefix_prepended(application, settings_admin_router) if AUTH_TYPE == AuthType.DISABLED: # Server logs this during auth setup verification step diff --git a/backend/danswer/server/settings/api.py b/backend/danswer/server/settings/api.py new file mode 100644 index 000000000..422e268c1 --- /dev/null +++ b/backend/danswer/server/settings/api.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException + +from danswer.auth.users import current_admin_user +from danswer.auth.users import current_user +from danswer.db.models import User +from danswer.server.settings.models import Settings +from danswer.server.settings.store import load_settings +from danswer.server.settings.store import store_settings + + +admin_router = APIRouter(prefix="/admin/settings") +basic_router = APIRouter(prefix="/settings") + + +@admin_router.put("") +def put_settings( + settings: Settings, _: User | None = Depends(current_admin_user) +) -> None: + try: + settings.check_validity() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + store_settings(settings) + + +@basic_router.get("") +def fetch_settings(_: User | None = Depends(current_user)) -> Settings: + return load_settings() diff --git a/backend/danswer/server/settings/models.py b/backend/danswer/server/settings/models.py new file mode 100644 index 000000000..041e360d7 --- /dev/null +++ b/backend/danswer/server/settings/models.py @@ -0,0 +1,36 @@ +from enum import Enum + +from pydantic import BaseModel + + +class PageType(str, Enum): + CHAT = "chat" + SEARCH = "search" + + +class Settings(BaseModel): + """General settings""" + + chat_page_enabled: bool = True + search_page_enabled: bool = True + default_page: PageType = PageType.SEARCH + + def check_validity(self) -> None: + chat_page_enabled = self.chat_page_enabled + search_page_enabled = self.search_page_enabled + default_page = self.default_page + + if chat_page_enabled is False and search_page_enabled is False: + raise ValueError( + "One of `search_page_enabled` and `chat_page_enabled` must be True." + ) + + if default_page == PageType.CHAT and chat_page_enabled is False: + raise ValueError( + "The default page cannot be 'chat' if the chat page is disabled." + ) + + if default_page == PageType.SEARCH and search_page_enabled is False: + raise ValueError( + "The default page cannot be 'search' if the search page is disabled." + ) diff --git a/backend/danswer/server/settings/store.py b/backend/danswer/server/settings/store.py new file mode 100644 index 000000000..ead1e3652 --- /dev/null +++ b/backend/danswer/server/settings/store.py @@ -0,0 +1,23 @@ +from typing import cast + +from danswer.dynamic_configs.factory import get_dynamic_config_store +from danswer.dynamic_configs.interface import ConfigNotFoundError +from danswer.server.settings.models import Settings + + +_SETTINGS_KEY = "danswer_settings" + + +def load_settings() -> Settings: + dynamic_config_store = get_dynamic_config_store() + try: + settings = Settings(**cast(dict, dynamic_config_store.load(_SETTINGS_KEY))) + except ConfigNotFoundError: + settings = Settings() + dynamic_config_store.store(_SETTINGS_KEY, settings.dict()) + + return settings + + +def store_settings(settings: Settings) -> None: + get_dynamic_config_store().store(_SETTINGS_KEY, settings.dict()) diff --git a/web/next.config.js b/web/next.config.js index 6f7de34ae..d7fc7a551 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -24,13 +24,7 @@ const nextConfig = { // In production, something else (nginx in the one box setup) should take // care of this redirect. TODO (chris): better support setups where // web_server and api_server are on different machines. - const defaultRedirects = [ - { - source: "/", - destination: "/search", - permanent: true, - }, - ]; + const defaultRedirects = []; if (process.env.NODE_ENV === "production") return defaultRedirects; diff --git a/web/src/app/admin/settings/SettingsForm.tsx b/web/src/app/admin/settings/SettingsForm.tsx new file mode 100644 index 000000000..3be9e3cb7 --- /dev/null +++ b/web/src/app/admin/settings/SettingsForm.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { Label, SubLabel } from "@/components/admin/connectors/Field"; +import { Title } from "@tremor/react"; +import { Settings } from "./interfaces"; +import { useRouter } from "next/navigation"; +import { DefaultDropdown, Option } from "@/components/Dropdown"; + +function Checkbox({ + label, + sublabel, + checked, + onChange, +}: { + label: string; + sublabel: string; + checked: boolean; + onChange: (e: React.ChangeEvent) => void; +}) { + return ( + + ); +} + +function Selector({ + label, + subtext, + options, + selected, + onSelect, +}: { + label: string; + subtext: string; + options: Option[]; + selected: string; + onSelect: (value: string | number | null) => void; +}) { + return ( +
+ {label && } + {subtext && {subtext}} + +
+ +
+
+ ); +} + +export function SettingsForm({ settings }: { settings: Settings }) { + const router = useRouter(); + + async function updateSettingField( + updateRequests: { fieldName: keyof Settings; newValue: any }[] + ) { + const newValues: any = {}; + updateRequests.forEach(({ fieldName, newValue }) => { + newValues[fieldName] = newValue; + }); + + const response = await fetch("/api/admin/settings", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...settings, + ...newValues, + }), + }); + if (response.ok) { + router.refresh(); + } else { + const errorMsg = (await response.json()).detail; + alert(`Failed to update settings. ${errorMsg}`); + } + } + + return ( +
+ Page Visibility + + { + const updates: any[] = [ + { fieldName: "search_page_enabled", newValue: e.target.checked }, + ]; + if (!e.target.checked && settings.default_page === "search") { + updates.push({ fieldName: "default_page", newValue: "chat" }); + } + updateSettingField(updates); + }} + /> + + { + const updates: any[] = [ + { fieldName: "chat_page_enabled", newValue: e.target.checked }, + ]; + if (!e.target.checked && settings.default_page === "chat") { + updates.push({ fieldName: "default_page", newValue: "search" }); + } + updateSettingField(updates); + }} + /> + + { + value && + updateSettingField([ + { fieldName: "default_page", newValue: value }, + ]); + }} + /> +
+ ); +} diff --git a/web/src/app/admin/settings/interfaces.ts b/web/src/app/admin/settings/interfaces.ts new file mode 100644 index 000000000..c62a39214 --- /dev/null +++ b/web/src/app/admin/settings/interfaces.ts @@ -0,0 +1,5 @@ +export interface Settings { + chat_page_enabled: boolean; + search_page_enabled: boolean; + default_page: "search" | "chat"; +} diff --git a/web/src/app/admin/settings/page.tsx b/web/src/app/admin/settings/page.tsx new file mode 100644 index 000000000..1a30495b5 --- /dev/null +++ b/web/src/app/admin/settings/page.tsx @@ -0,0 +1,33 @@ +import { AdminPageTitle } from "@/components/admin/Title"; +import { FiSettings } from "react-icons/fi"; +import { Settings } from "./interfaces"; +import { fetchSS } from "@/lib/utilsSS"; +import { SettingsForm } from "./SettingsForm"; +import { Callout, Text } from "@tremor/react"; + +export default async function Page() { + const response = await fetchSS("/settings"); + + if (!response.ok) { + const errorMsg = await response.text(); + return {errorMsg}; + } + + const settings = (await response.json()) as Settings; + + return ( +
+ } + /> + + + Manage general Danswer settings applicable to all users in the + workspace. + + + +
+ ); +} diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 7c132cc2c..1fc1b4e5f 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -9,9 +9,11 @@ import { Persona } from "../admin/personas/interfaces"; import { Header } from "@/components/Header"; import { HealthCheckBanner } from "@/components/health/healthcheck"; import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; +import { Settings } from "../admin/settings/interfaces"; export function ChatLayout({ user, + settings, chatSessions, availableSources, availableDocumentSets, @@ -21,6 +23,7 @@ export function ChatLayout({ documentSidebarInitialWidth, }: { user: User | null; + settings: Settings | null; chatSessions: ChatSession[]; availableSources: ValidSources[]; availableDocumentSets: DocumentSet[]; @@ -40,7 +43,7 @@ export function ChatLayout({ return ( <>
-
+
diff --git a/web/src/app/chat/page.tsx b/web/src/app/chat/page.tsx index 57de9de80..4e32d6ffc 100644 --- a/web/src/app/chat/page.tsx +++ b/web/src/app/chat/page.tsx @@ -27,6 +27,8 @@ import { personaComparator } from "../admin/personas/lib"; import { ChatLayout } from "./ChatPage"; import { FullEmbeddingModelResponse } from "../admin/models/embedding/embeddingModels"; import { NoCompleteSourcesModal } from "@/components/initialSetup/search/NoCompleteSourceModal"; +import { getSettingsSS } from "@/lib/settings"; +import { Settings } from "../admin/settings/interfaces"; export default async function Page({ searchParams, @@ -43,6 +45,7 @@ export default async function Page({ fetchSS("/persona?include_default=true"), fetchSS("/chat/get-user-chat-sessions"), fetchSS("/query/valid-tags"), + getSettingsSS(), ]; // catch cases where the backend is completely unreachable here @@ -53,8 +56,9 @@ export default async function Page({ | Response | AuthTypeMetadata | FullEmbeddingModelResponse + | Settings | null - )[] = [null, null, null, null, null, null, null, null, null]; + )[] = [null, null, null, null, null, null, null, null, null, null]; try { results = await Promise.all(tasks); } catch (e) { @@ -67,6 +71,7 @@ export default async function Page({ const personasResponse = results[4] as Response | null; const chatSessionsResponse = results[5] as Response | null; const tagsResponse = results[6] as Response | null; + const settings = results[7] as Settings | null; const authDisabled = authTypeMetadata?.authType === "disabled"; if (!authDisabled && !user) { @@ -77,6 +82,10 @@ export default async function Page({ return redirect("/auth/waiting-on-verification"); } + if (settings && !settings.chat_page_enabled) { + return redirect("/search"); + } + let ccPairs: CCPairBasicInfo[] = []; if (ccPairsResponse?.ok) { ccPairs = await ccPairsResponse.json(); @@ -172,6 +181,7 @@ export default async function Page({
-
+
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx new file mode 100644 index 000000000..c6b291d22 --- /dev/null +++ b/web/src/app/page.tsx @@ -0,0 +1,16 @@ +import { getSettingsSS } from "@/lib/settings"; +import { redirect } from "next/navigation"; + +export default async function Page() { + const settings = await getSettingsSS(); + + if (!settings) { + redirect("/search"); + } + + if (settings.default_page === "search") { + redirect("/search"); + } else { + redirect("/chat"); + } +} diff --git a/web/src/app/search/page.tsx b/web/src/app/search/page.tsx index fa7294033..2299ea772 100644 --- a/web/src/app/search/page.tsx +++ b/web/src/app/search/page.tsx @@ -23,6 +23,8 @@ import { personaComparator } from "../admin/personas/lib"; import { FullEmbeddingModelResponse } from "../admin/models/embedding/embeddingModels"; import { NoSourcesModal } from "@/components/initialSetup/search/NoSourcesModal"; import { NoCompleteSourcesModal } from "@/components/initialSetup/search/NoCompleteSourceModal"; +import { getSettingsSS } from "@/lib/settings"; +import { Settings } from "../admin/settings/interfaces"; export default async function Home() { // Disable caching so we always get the up to date connector / document set / persona info @@ -38,6 +40,7 @@ export default async function Home() { fetchSS("/persona"), fetchSS("/query/valid-tags"), fetchSS("/secondary-index/get-embedding-models"), + getSettingsSS(), ]; // catch cases where the backend is completely unreachable here @@ -48,6 +51,7 @@ export default async function Home() { | Response | AuthTypeMetadata | FullEmbeddingModelResponse + | Settings | null )[] = [null, null, null, null, null, null, null]; try { @@ -62,6 +66,7 @@ export default async function Home() { const personaResponse = results[4] as Response | null; const tagsResponse = results[5] as Response | null; const embeddingModelResponse = results[6] as Response | null; + const settings = results[7] as Settings | null; const authDisabled = authTypeMetadata?.authType === "disabled"; if (!authDisabled && !user) { @@ -72,6 +77,10 @@ export default async function Home() { return redirect("/auth/waiting-on-verification"); } + if (settings && !settings.search_page_enabled) { + return redirect("/chat"); + } + let ccPairs: CCPairBasicInfo[] = []; if (ccPairsResponse?.ok) { ccPairs = await ccPairsResponse.json(); @@ -143,7 +152,7 @@ export default async function Home() { return ( <> -
+
diff --git a/web/src/components/Dropdown.tsx b/web/src/components/Dropdown.tsx index 6637b8eb6..3cb1ba70d 100644 --- a/web/src/components/Dropdown.tsx +++ b/web/src/components/Dropdown.tsx @@ -1,7 +1,6 @@ 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 { name: string; @@ -12,108 +11,6 @@ export interface Option { export type StringOrNumberOption = Option; -interface DropdownProps { - options: Option[]; - selected: string; - onSelect: (selected: Option | null) => void; -} - -export const Dropdown = ({ - options, - selected, - onSelect, -}: DropdownProps) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - const selectedName = options.find( - (option) => option.value === selected - )?.name; - - const handleSelect = (option: StringOrNumberOption) => { - onSelect(option); - setIsOpen(false); - }; - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); - - return ( -
-
- -
- - {isOpen ? ( -
-
- {options.map((option, index) => ( - - ))} -
-
- ) : null} -
- ); -}; - function StandardDropdownOption({ index, option, diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index a4a04244d..7a28a0aa7 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -9,14 +9,15 @@ import React, { useEffect, useRef, useState } from "react"; import { CustomDropdown, DefaultDropdownElement } from "./Dropdown"; import { FiMessageSquare, FiSearch } from "react-icons/fi"; import { usePathname } from "next/navigation"; +import { Settings } from "@/app/admin/settings/interfaces"; interface HeaderProps { user: User | null; + settings: Settings | null; } -export const Header: React.FC = ({ user }) => { +export function Header({ user, settings }: HeaderProps) { const router = useRouter(); - const pathname = usePathname(); const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); @@ -56,7 +57,12 @@ export const Header: React.FC = ({ user }) => { return (
- +
Logo @@ -67,26 +73,31 @@ export const Header: React.FC = ({ user }) => {
- -
-
- -

Search

-
-
- + {(!settings || + (settings.search_page_enabled && settings.chat_page_enabled)) && ( + <> + +
+
+ +

Search

+
+
+ - -
-
- -

Chat

-
-
- + +
+
+ +

Chat

+
+
+ + + )}
@@ -124,7 +135,7 @@ export const Header: React.FC = ({ user }) => {
); -}; +} /* diff --git a/web/src/components/admin/Layout.tsx b/web/src/components/admin/Layout.tsx index fadeaee8d..0221f5172 100644 --- a/web/src/components/admin/Layout.tsx +++ b/web/src/components/admin/Layout.tsx @@ -1,3 +1,4 @@ +import { Settings } from "@/app/admin/settings/interfaces"; import { Header } from "@/components/Header"; import { AdminSidebar } from "@/components/admin/connectors/AdminSidebar"; import { @@ -12,6 +13,7 @@ import { ConnectorIcon, SlackIcon, } from "@/components/icons/icons"; +import { getSettingsSS } from "@/lib/settings"; import { User } from "@/lib/types"; import { AuthTypeMetadata, @@ -19,15 +21,21 @@ import { getCurrentUserSS, } from "@/lib/userSS"; import { redirect } from "next/navigation"; -import { FiCpu, FiLayers, FiPackage, FiSlack } from "react-icons/fi"; +import { + FiCpu, + FiLayers, + FiPackage, + FiSettings, + FiSlack, +} from "react-icons/fi"; export async function Layout({ children }: { children: React.ReactNode }) { - const tasks = [getAuthTypeMetadataSS(), getCurrentUserSS()]; + const tasks = [getAuthTypeMetadataSS(), getCurrentUserSS(), getSettingsSS()]; // catch cases where the backend is completely unreachable here // without try / catch, will just raise an exception and the page // will not render - let results: (User | AuthTypeMetadata | null)[] = [null, null]; + let results: (User | AuthTypeMetadata | Settings | null)[] = [null, null]; try { results = await Promise.all(tasks); } catch (e) { @@ -36,6 +44,7 @@ export async function Layout({ children }: { children: React.ReactNode }) { const authTypeMetadata = results[0] as AuthTypeMetadata | null; const user = results[1] as User | null; + const settings = results[2] as Settings | null; const authDisabled = authTypeMetadata?.authType === "disabled"; const requiresVerification = authTypeMetadata?.requiresVerification; @@ -54,7 +63,7 @@ export async function Layout({ children }: { children: React.ReactNode }) { return (
-
+
@@ -175,6 +184,20 @@ export async function Layout({ children }: { children: React.ReactNode }) { }, ], }, + { + name: "Settings", + items: [ + { + name: ( +
+ +
Workspace Settings
+
+ ), + link: "/admin/settings", + }, + ], + }, ]} />
diff --git a/web/src/lib/settings.ts b/web/src/lib/settings.ts new file mode 100644 index 000000000..76cfd143c --- /dev/null +++ b/web/src/lib/settings.ts @@ -0,0 +1,10 @@ +import { Settings } from "@/app/admin/settings/interfaces"; +import { buildUrl } from "./utilsSS"; + +export async function getSettingsSS(): Promise { + const response = await fetch(buildUrl("/settings")); + if (response.ok) { + return await response.json(); + } + return null; +}