Add ability to control available pages

This commit is contained in:
Weves 2024-03-31 21:42:30 -07:00 committed by Chris Weaver
parent 15f7b42e2b
commit a4869b727d
17 changed files with 399 additions and 152 deletions

View File

@ -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

View File

@ -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()

View File

@ -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."
)

View File

@ -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())

View File

@ -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;

View File

@ -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<HTMLInputElement>) => void;
}) {
return (
<label className="flex text-sm mb-4">
<input
checked={checked}
onChange={onChange}
type="checkbox"
className="mx-3 px-5 w-3.5 h-3.5 my-auto"
/>
<div>
<Label>{label}</Label>
<SubLabel>{sublabel}</SubLabel>
</div>
</label>
);
}
function Selector({
label,
subtext,
options,
selected,
onSelect,
}: {
label: string;
subtext: string;
options: Option<string>[];
selected: string;
onSelect: (value: string | number | null) => void;
}) {
return (
<div>
{label && <Label>{label}</Label>}
{subtext && <SubLabel>{subtext}</SubLabel>}
<div className="mt-2">
<DefaultDropdown
options={options}
selected={selected}
onSelect={onSelect}
/>
</div>
</div>
);
}
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 (
<div>
<Title className="mb-4">Page Visibility</Title>
<Checkbox
label="Search Page Enabled?"
sublabel={`If set, then the "Search" page will be accessible to all users
and will show up as an option on the top navbar. If unset, then this
page will not be available.`}
checked={settings.search_page_enabled}
onChange={(e) => {
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);
}}
/>
<Checkbox
label="Chat Page Enabled?"
sublabel={`If set, then the "Chat" page will be accessible to all users
and will show up as an option on the top navbar. If unset, then this
page will not be available.`}
checked={settings.chat_page_enabled}
onChange={(e) => {
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);
}}
/>
<Selector
label="Default Page"
subtext="The page that users will be redirected to after logging in. Can only be set to a page that is enabled."
options={[
{ value: "search", name: "Search" },
{ value: "chat", name: "Chat" },
]}
selected={settings.default_page}
onSelect={(value) => {
value &&
updateSettingField([
{ fieldName: "default_page", newValue: value },
]);
}}
/>
</div>
);
}

View File

@ -0,0 +1,5 @@
export interface Settings {
chat_page_enabled: boolean;
search_page_enabled: boolean;
default_page: "search" | "chat";
}

View File

@ -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 <Callout title="Failed to fetch settings">{errorMsg}</Callout>;
}
const settings = (await response.json()) as Settings;
return (
<div className="mx-auto container">
<AdminPageTitle
title="Workspace Settings"
icon={<FiSettings size={32} className="my-auto" />}
/>
<Text className="mb-8">
Manage general Danswer settings applicable to all users in the
workspace.
</Text>
<SettingsForm settings={settings} />
</div>
);
}

View File

@ -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 (
<>
<div className="absolute top-0 z-40 w-full">
<Header user={user} />
<Header user={user} settings={settings} />
</div>
<HealthCheckBanner />
<InstantSSRAutoRefresh />

View File

@ -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({
<ChatLayout
user={user}
settings={settings}
chatSessions={chatSessions}
availableSources={availableSources}
availableDocumentSets={documentSets}

View File

@ -9,6 +9,8 @@ import { redirect } from "next/navigation";
import { BackendChatSession } from "../../interfaces";
import { Header } from "@/components/Header";
import { SharedChatDisplay } from "./SharedChatDisplay";
import { getSettingsSS } from "@/lib/settings";
import { Settings } from "@/app/admin/settings/interfaces";
async function getSharedChat(chatId: string) {
const response = await fetchSS(
@ -25,22 +27,13 @@ export default async function Page({ params }: { params: { chatId: string } }) {
getAuthTypeMetadataSS(),
getCurrentUserSS(),
getSharedChat(params.chatId),
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,
null,
null,
null,
null,
null,
null,
null,
];
let results: (User | AuthTypeMetadata | null)[] = [null, null, null, null];
try {
results = await Promise.all(tasks);
} catch (e) {
@ -49,6 +42,7 @@ export default async function Page({ params }: { params: { chatId: string } }) {
const authTypeMetadata = results[0] as AuthTypeMetadata | null;
const user = results[1] as User | null;
const chatSession = results[2] as BackendChatSession | null;
const settings = results[3] as Settings | null;
const authDisabled = authTypeMetadata?.authType === "disabled";
if (!authDisabled && !user) {
@ -62,7 +56,7 @@ export default async function Page({ params }: { params: { chatId: string } }) {
return (
<div>
<div className="absolute top-0 z-40 w-full">
<Header user={user} />
<Header user={user} settings={settings} />
</div>
<div className="flex relative bg-background text-default overflow-hidden pt-16 h-screen">

16
web/src/app/page.tsx Normal file
View File

@ -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");
}
}

View File

@ -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 (
<>
<Header user={user} />
<Header user={user} settings={settings} />
<div className="m-3">
<HealthCheckBanner />
</div>

View File

@ -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<T> {
name: string;
@ -12,108 +11,6 @@ export interface Option<T> {
export type StringOrNumberOption = Option<string | number>;
interface DropdownProps<T> {
options: Option<T>[];
selected: string;
onSelect: (selected: Option<T> | null) => void;
}
export const Dropdown = ({
options,
selected,
onSelect,
}: DropdownProps<string | number>) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className="relative inline-block text-left w-full" ref={dropdownRef}>
<div>
<button
type="button"
className={`inline-flex
justify-center
w-full
px-4
py-3
text-sm
bg-gray-700
border
border-gray-300
rounded-md
shadow-sm
hover:bg-gray-700
focus:ring focus:ring-offset-0 focus:ring-1 focus:ring-offset-gray-800 focus:ring-blue-800
`}
id="options-menu"
aria-expanded="true"
aria-haspopup="true"
onClick={() => setIsOpen(!isOpen)}
>
{selectedName ? <p>{selectedName}</p> : "Select an option..."}
<ChevronDownIcon className="text-gray-400 my-auto ml-auto" />
</button>
</div>
{isOpen ? (
<div className="origin-top-right absolute left-0 mt-3 w-full rounded-md shadow-lg bg-gray-700 border-2 border-gray-600">
<div
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
{options.map((option, index) => (
<button
key={index}
onClick={() => handleSelect(option)}
className={
`w-full text-left block px-4 py-2.5 text-sm hover:bg-gray-800` +
(index !== 0 ? " border-t-2 border-gray-600" : "")
}
role="menuitem"
>
<p className="font-medium">{option.name}</p>
{option.description && (
<div>
<p className="text-xs text-gray-300">
{option.description}
</p>
</div>
)}
</button>
))}
</div>
</div>
) : null}
</div>
);
};
function StandardDropdownOption<T>({
index,
option,

View File

@ -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<HeaderProps> = ({ user }) => {
export function Header({ user, settings }: HeaderProps) {
const router = useRouter();
const pathname = usePathname();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -56,7 +57,12 @@ export const Header: React.FC<HeaderProps> = ({ user }) => {
return (
<header className="border-b border-border bg-background-emphasis">
<div className="mx-8 flex h-16">
<Link className="py-4" href="/search">
<Link
className="py-4"
href={
settings && settings.default_page === "chat" ? "/chat" : "/search"
}
>
<div className="flex">
<div className="h-[32px] w-[30px]">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
@ -67,26 +73,31 @@ export const Header: React.FC<HeaderProps> = ({ user }) => {
</div>
</Link>
<Link
href="/search"
className={"ml-6 h-full flex flex-col hover:bg-hover"}
>
<div className="w-24 flex my-auto">
<div className={"mx-auto flex text-strong px-2"}>
<FiSearch className="my-auto mr-1" />
<h1 className="flex text-sm font-bold my-auto">Search</h1>
</div>
</div>
</Link>
{(!settings ||
(settings.search_page_enabled && settings.chat_page_enabled)) && (
<>
<Link
href="/search"
className={"ml-6 h-full flex flex-col hover:bg-hover"}
>
<div className="w-24 flex my-auto">
<div className={"mx-auto flex text-strong px-2"}>
<FiSearch className="my-auto mr-1" />
<h1 className="flex text-sm font-bold my-auto">Search</h1>
</div>
</div>
</Link>
<Link href="/chat" className="h-full flex flex-col hover:bg-hover">
<div className="w-24 flex my-auto">
<div className="mx-auto flex text-strong px-2">
<FiMessageSquare className="my-auto mr-1" />
<h1 className="flex text-sm font-bold my-auto">Chat</h1>
</div>
</div>
</Link>
<Link href="/chat" className="h-full flex flex-col hover:bg-hover">
<div className="w-24 flex my-auto">
<div className="mx-auto flex text-strong px-2">
<FiMessageSquare className="my-auto mr-1" />
<h1 className="flex text-sm font-bold my-auto">Chat</h1>
</div>
</div>
</Link>
</>
)}
<div className="ml-auto h-full flex flex-col">
<div className="my-auto">
@ -124,7 +135,7 @@ export const Header: React.FC<HeaderProps> = ({ user }) => {
</div>
</header>
);
};
}
/*

View File

@ -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 (
<div className="h-screen overflow-y-hidden">
<div className="absolute top-0 z-50 w-full">
<Header user={user} />
<Header user={user} settings={settings} />
</div>
<div className="flex h-full pt-16">
<div className="w-80 pt-12 pb-8 h-full border-r border-border">
@ -175,6 +184,20 @@ export async function Layout({ children }: { children: React.ReactNode }) {
},
],
},
{
name: "Settings",
items: [
{
name: (
<div className="flex">
<FiSettings size={18} />
<div className="ml-1">Workspace Settings</div>
</div>
),
link: "/admin/settings",
},
],
},
]}
/>
</div>

10
web/src/lib/settings.ts Normal file
View File

@ -0,0 +1,10 @@
import { Settings } from "@/app/admin/settings/interfaces";
import { buildUrl } from "./utilsSS";
export async function getSettingsSS(): Promise<Settings | null> {
const response = await fetch(buildUrl("/settings"));
if (response.ok) {
return await response.json();
}
return null;
}