From fd8b11c6db633b633616383e61a0a7965f620d1c Mon Sep 17 00:00:00 2001 From: pablodanswer Date: Tue, 15 Oct 2024 19:49:31 -0700 Subject: [PATCH] add assistant notifications --- ...030_add_additional_data_to_notifiations.py | 26 ++++++ backend/danswer/configs/app_configs.py | 2 +- backend/danswer/configs/constants.py | 1 + backend/danswer/db/models.py | 3 + backend/danswer/db/notification.py | 6 +- backend/danswer/main.py | 2 + .../server/features/notifications/api.py | 46 +++++++++ .../danswer/server/features/persona/api.py | 14 +++ .../danswer/server/features/persona/models.py | 4 + backend/danswer/server/settings/api.py | 26 +----- backend/danswer/server/settings/models.py | 2 + .../docker_compose/docker-compose.dev.yml | 2 +- .../docker_compose/docker-compose.gpu-dev.yml | 2 +- .../docker-compose.search-testing.yml | 2 +- web/src/app/chat/interfaces.ts | 9 ++ web/src/components/UserDropdown.tsx | 6 +- web/src/components/chat_search/Header.tsx | 21 ++++- .../components/chat_search/Notification.tsx | 93 +++++++++++++++++++ web/src/lib/userSS.ts | 9 ++ 19 files changed, 241 insertions(+), 35 deletions(-) create mode 100644 backend/alembic/versions/1b10e1fda030_add_additional_data_to_notifiations.py create mode 100644 backend/danswer/server/features/notifications/api.py create mode 100644 web/src/components/chat_search/Notification.tsx diff --git a/backend/alembic/versions/1b10e1fda030_add_additional_data_to_notifiations.py b/backend/alembic/versions/1b10e1fda030_add_additional_data_to_notifiations.py new file mode 100644 index 000000000..3cb166047 --- /dev/null +++ b/backend/alembic/versions/1b10e1fda030_add_additional_data_to_notifiations.py @@ -0,0 +1,26 @@ +"""add additional data to notifiations + +Revision ID: 1b10e1fda030 +Revises: 5d12a446f5c0 +Create Date: 2024-10-15 19:26:44.071259 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "1b10e1fda030" +down_revision = "5d12a446f5c0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "notification", sa.Column("additional_data", postgresql.JSONB(), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("notification", "additional_data") diff --git a/backend/danswer/configs/app_configs.py b/backend/danswer/configs/app_configs.py index 0d8b7da70..2497730bf 100644 --- a/backend/danswer/configs/app_configs.py +++ b/backend/danswer/configs/app_configs.py @@ -134,7 +134,7 @@ POSTGRES_PASSWORD = urllib.parse.quote_plus( os.environ.get("POSTGRES_PASSWORD") or "password" ) POSTGRES_HOST = os.environ.get("POSTGRES_HOST") or "localhost" -POSTGRES_PORT = os.environ.get("POSTGRES_PORT") or "5432" +POSTGRES_PORT = os.environ.get("POSTGRES_PORT") or "5433" POSTGRES_DB = os.environ.get("POSTGRES_DB") or "postgres" POSTGRES_API_SERVER_POOL_SIZE = int( diff --git a/backend/danswer/configs/constants.py b/backend/danswer/configs/constants.py index 6b167246a..29c550e36 100644 --- a/backend/danswer/configs/constants.py +++ b/backend/danswer/configs/constants.py @@ -123,6 +123,7 @@ DocumentSourceRequiringTenantContext: list[DocumentSource] = [DocumentSource.FIL class NotificationType(str, Enum): REINDEX = "reindex" + PERSONA_SHARED = "persona_shared" class BlobType(str, Enum): diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index 2101fc74e..bee693534 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -235,6 +235,9 @@ class Notification(Base): first_shown: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True)) user: Mapped[User] = relationship("User", back_populates="notifications") + additional_data: Mapped[dict | None] = mapped_column( + postgresql.JSONB(), nullable=True + ) """ diff --git a/backend/danswer/db/notification.py b/backend/danswer/db/notification.py index 61586208c..80aa588e7 100644 --- a/backend/danswer/db/notification.py +++ b/backend/danswer/db/notification.py @@ -1,3 +1,5 @@ +from uuid import UUID + from sqlalchemy import select from sqlalchemy.orm import Session from sqlalchemy.sql import func @@ -8,12 +10,12 @@ from danswer.db.models import User def create_notification( - user: User | None, + user_id: UUID | None, notif_type: NotificationType, db_session: Session, ) -> Notification: notification = Notification( - user_id=user.id if user else None, + user_id=user_id, notif_type=notif_type, dismissed=False, last_shown=func.now(), diff --git a/backend/danswer/main.py b/backend/danswer/main.py index ea3382632..9dc2e7906 100644 --- a/backend/danswer/main.py +++ b/backend/danswer/main.py @@ -57,6 +57,7 @@ from danswer.server.features.input_prompt.api import ( admin_router as admin_input_prompt_router, ) from danswer.server.features.input_prompt.api import basic_router as input_prompt_router +from danswer.server.features.notifications.api import router as notification_router from danswer.server.features.persona.api import admin_router as admin_persona_router from danswer.server.features.persona.api import basic_router as persona_router from danswer.server.features.prompt.api import basic_router as prompt_router @@ -243,6 +244,7 @@ def get_application() -> FastAPI: include_router_with_global_prefix_prepended(application, admin_persona_router) include_router_with_global_prefix_prepended(application, input_prompt_router) include_router_with_global_prefix_prepended(application, admin_input_prompt_router) + include_router_with_global_prefix_prepended(application, notification_router) include_router_with_global_prefix_prepended(application, prompt_router) include_router_with_global_prefix_prepended(application, tool_router) include_router_with_global_prefix_prepended(application, admin_tool_router) diff --git a/backend/danswer/server/features/notifications/api.py b/backend/danswer/server/features/notifications/api.py new file mode 100644 index 000000000..e5340a1a7 --- /dev/null +++ b/backend/danswer/server/features/notifications/api.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from danswer.auth.users import current_user +from danswer.db.engine import get_session +from danswer.db.models import User +from danswer.db.notification import dismiss_notification +from danswer.db.notification import get_notification_by_id +from danswer.db.notification import get_notifications +from danswer.server.settings.models import Notification as NotificationModel +from danswer.utils.logger import setup_logger + +logger = setup_logger() + +router = APIRouter(prefix="/notifications") + + +@router.get("/") +def get_notifications_api( + user: User = Depends(current_user), + db_session: Session = Depends(get_session), +) -> list[NotificationModel]: + return [ + NotificationModel.from_model(notif) + for notif in get_notifications(user, db_session, include_dismissed=False) + ] + + +@router.post("/{notification_id}/dismiss") +def dismiss_notification_endpoint( + notification_id: int, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> None: + try: + notification = get_notification_by_id(notification_id, user, db_session) + except PermissionError: + raise HTTPException( + status_code=403, detail="Not authorized to dismiss this notification" + ) + except ValueError: + raise HTTPException(status_code=404, detail="Notification not found") + + dismiss_notification(notification, db_session) diff --git a/backend/danswer/server/features/persona/api.py b/backend/danswer/server/features/persona/api.py index 8b4305755..ee6f75612 100644 --- a/backend/danswer/server/features/persona/api.py +++ b/backend/danswer/server/features/persona/api.py @@ -13,8 +13,10 @@ from danswer.auth.users import current_admin_user from danswer.auth.users import current_curator_or_admin_user from danswer.auth.users import current_user from danswer.configs.constants import FileOrigin +from danswer.configs.constants import NotificationType from danswer.db.engine import get_session from danswer.db.models import User +from danswer.db.notification import create_notification from danswer.db.persona import create_update_persona from danswer.db.persona import get_persona_by_id from danswer.db.persona import get_personas @@ -28,6 +30,7 @@ from danswer.file_store.file_store import get_default_file_store from danswer.file_store.models import ChatFileType from danswer.llm.answering.prompts.utils import build_dummy_prompt from danswer.server.features.persona.models import CreatePersonaRequest +from danswer.server.features.persona.models import PersonaSharedNotificationData from danswer.server.features.persona.models import PersonaSnapshot from danswer.server.features.persona.models import PromptTemplateResponse from danswer.server.models import DisplayPriorityRequest @@ -183,6 +186,7 @@ class PersonaShareRequest(BaseModel): user_ids: list[UUID] +# We notify each user when a user is shared with them @basic_router.patch("/{persona_id}/share") def share_persona( persona_id: int, @@ -197,6 +201,16 @@ def share_persona( db_session=db_session, ) + for user_id in persona_share_request.user_ids: + create_notification( + user_id=user_id, + notif_type=NotificationType.PERSONA_SHARED, + db_session=db_session, + additional_data=PersonaSharedNotificationData( + persona_id=persona_id, + ).model_dump(), + ) + @basic_router.delete("/{persona_id}") def delete_persona( diff --git a/backend/danswer/server/features/persona/models.py b/backend/danswer/server/features/persona/models.py index 016defda3..866d70f11 100644 --- a/backend/danswer/server/features/persona/models.py +++ b/backend/danswer/server/features/persona/models.py @@ -120,3 +120,7 @@ class PersonaSnapshot(BaseModel): class PromptTemplateResponse(BaseModel): final_prompt_template: str + + +class PersonaSharedNotificationData(BaseModel): + persona_id: int diff --git a/backend/danswer/server/settings/api.py b/backend/danswer/server/settings/api.py index 48157253c..35ab0b12c 100644 --- a/backend/danswer/server/settings/api.py +++ b/backend/danswer/server/settings/api.py @@ -15,8 +15,6 @@ from danswer.db.engine import get_session from danswer.db.models import User from danswer.db.notification import create_notification from danswer.db.notification import dismiss_all_notifications -from danswer.db.notification import dismiss_notification -from danswer.db.notification import get_notification_by_id from danswer.db.notification import get_notifications from danswer.db.notification import update_notification_last_shown from danswer.key_value_store.factory import get_kv_store @@ -55,7 +53,7 @@ def fetch_settings( """Settings and notifications are stuffed into this single endpoint to reduce number of Postgres calls""" general_settings = load_settings() - user_notifications = get_user_notifications(user, db_session) + user_notifications = get_reindex_notification(user, db_session) try: kv_store = get_kv_store() @@ -70,25 +68,7 @@ def fetch_settings( ) -@basic_router.post("/notifications/{notification_id}/dismiss") -def dismiss_notification_endpoint( - notification_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - try: - notification = get_notification_by_id(notification_id, user, db_session) - except PermissionError: - raise HTTPException( - status_code=403, detail="Not authorized to dismiss this notification" - ) - except ValueError: - raise HTTPException(status_code=404, detail="Notification not found") - - dismiss_notification(notification, db_session) - - -def get_user_notifications( +def get_reindex_notification( user: User | None, db_session: Session ) -> list[Notification]: """Get notifications for the user, currently the logic is very specific to the reindexing flag""" @@ -121,7 +101,7 @@ def get_user_notifications( if not reindex_notifs: notif = create_notification( - user=user, + user_id=user.id if user else None, notif_type=NotificationType.REINDEX, db_session=db_session, ) diff --git a/backend/danswer/server/settings/models.py b/backend/danswer/server/settings/models.py index 6713f7f67..af9359550 100644 --- a/backend/danswer/server/settings/models.py +++ b/backend/danswer/server/settings/models.py @@ -24,6 +24,7 @@ class Notification(BaseModel): dismissed: bool last_shown: datetime first_shown: datetime + additional_data: dict | None = None @classmethod def from_model(cls, notif: NotificationDBModel) -> "Notification": @@ -33,6 +34,7 @@ class Notification(BaseModel): dismissed=notif.dismissed, last_shown=notif.last_shown, first_shown=notif.first_shown, + additional_data=notif.additional_data, ) diff --git a/deployment/docker_compose/docker-compose.dev.yml b/deployment/docker_compose/docker-compose.dev.yml index 6f4619f83..910e2934c 100644 --- a/deployment/docker_compose/docker-compose.dev.yml +++ b/deployment/docker_compose/docker-compose.dev.yml @@ -312,7 +312,7 @@ services: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} ports: - - "5432:5432" + - "5433:5432" volumes: - db_volume:/var/lib/postgresql/data diff --git a/deployment/docker_compose/docker-compose.gpu-dev.yml b/deployment/docker_compose/docker-compose.gpu-dev.yml index 6397f657c..03e436a2e 100644 --- a/deployment/docker_compose/docker-compose.gpu-dev.yml +++ b/deployment/docker_compose/docker-compose.gpu-dev.yml @@ -312,7 +312,7 @@ services: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} ports: - - "5432:5432" + - "5433:5432" volumes: - db_volume:/var/lib/postgresql/data diff --git a/deployment/docker_compose/docker-compose.search-testing.yml b/deployment/docker_compose/docker-compose.search-testing.yml index fab950c06..2afd54e02 100644 --- a/deployment/docker_compose/docker-compose.search-testing.yml +++ b/deployment/docker_compose/docker-compose.search-testing.yml @@ -157,7 +157,7 @@ services: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} ports: - - "5432" + - "5433" volumes: - db_volume:/var/lib/postgresql/data diff --git a/web/src/app/chat/interfaces.ts b/web/src/app/chat/interfaces.ts index 5e25566a7..44d2d0e72 100644 --- a/web/src/app/chat/interfaces.ts +++ b/web/src/app/chat/interfaces.ts @@ -59,6 +59,15 @@ export interface ToolCallFinalResult { tool_result: Record; } +export interface Notification { + id: string; + title: string; + message: string; + time_created: string; + dismissed: boolean; + additional_data?: Record; +} + export interface ChatSession { id: string; name: string; diff --git a/web/src/components/UserDropdown.tsx b/web/src/components/UserDropdown.tsx index 00c3b83c1..2642c27c6 100644 --- a/web/src/components/UserDropdown.tsx +++ b/web/src/components/UserDropdown.tsx @@ -9,11 +9,7 @@ import { checkUserIsNoAuthUser, logout } from "@/lib/user"; import { Popover } from "./popover/Popover"; import { LOGOUT_DISABLED } from "@/lib/constants"; import { SettingsContext } from "./settings/SettingsProvider"; -import { - AssistantsIconSkeleton, - LightSettingsIcon, - UsersIcon, -} from "./icons/icons"; +import { LightSettingsIcon } from "./icons/icons"; import { pageType } from "@/app/chat/sessionSidebar/types"; import { NavigationItem } from "@/app/admin/settings/interfaces"; import DynamicFaIcon, { preloadIcons } from "./icons/DynamicFaIcon"; diff --git a/web/src/components/chat_search/Header.tsx b/web/src/components/chat_search/Header.tsx index c19d46ce8..97225ef61 100644 --- a/web/src/components/chat_search/Header.tsx +++ b/web/src/components/chat_search/Header.tsx @@ -5,12 +5,15 @@ import { FiShare2 } from "react-icons/fi"; import { SetStateAction, useContext, useEffect } from "react"; import { NewChatIcon } from "../icons/icons"; import { NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA } from "@/lib/constants"; -import { ChatSession } from "@/app/chat/interfaces"; +import { ChatSession, Notification } from "@/app/chat/interfaces"; import Link from "next/link"; import { pageType } from "@/app/chat/sessionSidebar/types"; import { useRouter } from "next/navigation"; import { ChatBanner } from "@/app/chat/ChatBanner"; import LogoType from "../header/LogoType"; +import useSWR from "swr"; +import { errorHandlingFetcher } from "@/lib/fetcher"; +import { NotificationCard } from "./Notification"; export default function FunctionalHeader({ user, @@ -54,6 +57,18 @@ export default function FunctionalHeader({ }, [page, currentChatSession]); const router = useRouter(); + const { + data: notifications, + error, + mutate: refreshNotifications, + } = useSWR("/api/notifications", errorHandlingFetcher); + + useEffect(() => { + if (error) { + console.error("Failed to fetch notificat ions:", error); + } + }, [error]); + const handleNewChat = () => { reset(); const newChatUrl = @@ -108,6 +123,10 @@ export default function FunctionalHeader({ )} +
diff --git a/web/src/components/chat_search/Notification.tsx b/web/src/components/chat_search/Notification.tsx new file mode 100644 index 000000000..43bb2eefa --- /dev/null +++ b/web/src/components/chat_search/Notification.tsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; + +import { Notification } from "../../app/chat/interfaces"; + +export const NotificationCard = ({ + notifications, + refreshNotifications, +}: { + notifications?: Notification[]; + refreshNotifications: () => void; +}) => { + const [showDropdown, setShowDropdown] = useState(false); + + const dismissNotification = async (notificationId: string) => { + try { + await fetch(`/api/notifications/${notificationId}/dismiss`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("Error dismissing notification:", error); + } + }; + + const handleDismiss = async (notificationId: string) => { + try { + await dismissNotification(notificationId); + refreshNotifications(); + } catch (error) { + console.error("Error dismissing notification:", error); + } + }; + + const handleAccept = async (notification: Notification) => { + // Handle accept logic based on notification.additional_data + // For example, accept a shared persona + // await acceptSharedPersona(notification.additional_data.persona_id); + // Then dismiss the notification + await handleDismiss(notification.id); + }; + if (!notifications) { + return null; + } + + return ( +
+
setShowDropdown(!showDropdown)} + className="cursor-pointer" + > + + {/* Bell icon SVG */} + + + {notifications.length > 0 && ( + + )} +
+ {showDropdown && ( +
+ {notifications.length > 0 ? ( + notifications.map((notification) => ( +
+

{notification.title}

+

{notification.message}

+
+ + +
+
+ )) + ) : ( +
+ No new notifications +
+ )} +
+ )} +
+ ); +}; diff --git a/web/src/lib/userSS.ts b/web/src/lib/userSS.ts index 81261cebe..d0272b85b 100644 --- a/web/src/lib/userSS.ts +++ b/web/src/lib/userSS.ts @@ -155,3 +155,12 @@ export const processCookies = (cookies: ReadonlyRequestCookies): string => { .map((cookie) => `${cookie.name}=${cookie.value}`) .join("; "); }; + +export const getNotificationsSS = async (): Promise => { + const response = await fetch(buildUrl("/notifications")); + if (!response.ok) { + throw new Error("Failed to fetch notifications"); + } + const notifications = await response.json(); + return notifications; +};