Add assistant notifications + update assistant context (#2816)

* add assistant notifications

* nit

* update context

* validated

* ensure context passed properly

* validated + cleaned

* nit: naming

* k

* k

* final validation + new ui

* nit + video

* nit

* nit

* nit

* k

* fix typos
This commit is contained in:
pablodanswer 2024-10-18 18:21:11 -07:00 committed by GitHub
parent 6913efef90
commit 8b220d2dba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 820 additions and 344 deletions

View File

@ -0,0 +1,26 @@
"""add additional data to notifications
Revision ID: 1b10e1fda030
Revises: 6756efa39ada
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 = "6756efa39ada"
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")

View File

@ -135,6 +135,7 @@ DocumentSourceRequiringTenantContext: list[DocumentSource] = [DocumentSource.FIL
class NotificationType(str, Enum):
REINDEX = "reindex"
PERSONA_SHARED = "persona_shared"
class BlobType(str, Enum):

View File

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

View File

@ -1,3 +1,5 @@
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.sql import func
@ -8,16 +10,37 @@ from danswer.db.models import User
def create_notification(
user: User | None,
user_id: UUID | None,
notif_type: NotificationType,
db_session: Session,
additional_data: dict | None = None,
) -> Notification:
# Check if an undismissed notification of the same type and data exists
existing_notification = (
db_session.query(Notification)
.filter_by(
user_id=user_id,
notif_type=notif_type,
dismissed=False,
)
.filter(Notification.additional_data == additional_data)
.first()
)
if existing_notification:
# Update the last_shown timestamp
existing_notification.last_shown = func.now()
db_session.commit()
return existing_notification
# Create a new notification if none exists
notification = Notification(
user_id=user.id if user else None,
user_id=user_id,
notif_type=notif_type,
dismissed=False,
last_shown=func.now(),
first_shown=func.now(),
additional_data=additional_data,
)
db_session.add(notification)
db_session.commit()

View File

@ -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
@ -246,6 +247,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)

View File

@ -0,0 +1,47 @@
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]:
notifications = [
NotificationModel.from_model(notif)
for notif in get_notifications(user, db_session, include_dismissed=False)
]
return notifications
@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)

View File

@ -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,11 +186,12 @@ 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,
persona_share_request: PersonaShareRequest,
user: User | None = Depends(current_user),
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
update_persona_shared_users(
@ -197,6 +201,18 @@ def share_persona(
db_session=db_session,
)
for user_id in persona_share_request.user_ids:
# Don't notify the user that they have access to their own persona
if user_id != user.id:
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(
@ -216,23 +232,31 @@ def list_personas(
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
include_deleted: bool = False,
persona_ids: list[int] = Query(None),
) -> list[PersonaSnapshot]:
return [
PersonaSnapshot.from_model(persona)
for persona in get_personas(
user=user,
include_deleted=include_deleted,
db_session=db_session,
get_editable=False,
joinedload_all=True,
)
# If the persona has an image generation tool and it's not available, don't include it
personas = get_personas(
user=user,
include_deleted=include_deleted,
db_session=db_session,
get_editable=False,
joinedload_all=True,
)
if persona_ids:
personas = [p for p in personas if p.id in persona_ids]
# Filter out personas with unavailable tools
personas = [
p
for p in personas
if not (
any(tool.in_code_tool_id == "ImageGenerationTool" for tool in persona.tools)
any(tool.in_code_tool_id == "ImageGenerationTool" for tool in p.tools)
and not is_image_generation_available(db_session=db_session)
)
]
return [PersonaSnapshot.from_model(p) for p in personas]
@basic_router.get("/{persona_id}")
def get_persona(

View File

@ -120,3 +120,7 @@ class PersonaSnapshot(BaseModel):
class PromptTemplateResponse(BaseModel):
final_prompt_template: str
class PersonaSharedNotificationData(BaseModel):
persona_id: int

View File

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

View File

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

View File

@ -15,12 +15,20 @@ export interface Settings {
product_gating: GatingType;
}
export enum NotificationType {
PERSONA_SHARED = "persona_shared",
REINDEX_NEEDED = "reindex_needed",
}
export interface Notification {
id: number;
notif_type: string;
time_created: string;
dismissed: boolean;
last_shown: string;
first_shown: string;
additional_data?: {
persona_id?: number;
[key: string]: any;
};
}
export interface NavigationItem {

View File

@ -26,14 +26,9 @@ interface SidebarWrapperProps<T extends object> {
folders?: Folder[];
initiallyToggled: boolean;
openedFolders?: { [key: number]: boolean };
content: (props: T) => ReactNode;
headerProps: {
page: pageType;
user: User | null;
};
contentProps: T;
page: pageType;
size?: "sm" | "lg";
children: ReactNode;
}
export default function SidebarWrapper<T extends object>({
@ -42,10 +37,8 @@ export default function SidebarWrapper<T extends object>({
folders,
openedFolders,
page,
headerProps,
contentProps,
content,
size = "sm",
children,
}: SidebarWrapperProps<T>) {
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
const [showDocSidebar, setShowDocSidebar] = useState(false); // State to track if sidebar is open
@ -144,7 +137,6 @@ export default function SidebarWrapper<T extends object>({
sidebarToggled={toggledSidebar}
toggleSidebar={toggleSidebar}
page="assistants"
user={headerProps.user}
/>
<div className="w-full flex">
<div
@ -163,7 +155,7 @@ export default function SidebarWrapper<T extends object>({
<div
className={`mt-4 w-full ${size == "lg" ? "max-w-4xl" : "max-w-3xl"} mx-auto`}
>
{content(contentProps)}
{children}
</div>
</div>
</div>

View File

@ -15,6 +15,8 @@ import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { useRouter } from "next/navigation";
import { AssistantTools } from "../ToolsDisplay";
import { classifyAssistants } from "@/lib/assistants/utils";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useUser } from "@/components/user/UserProvider";
export function AssistantGalleryCard({
assistant,
user,
@ -26,6 +28,7 @@ export function AssistantGalleryCard({
setPopup: (popup: PopupSpec) => void;
selectedAssistant: boolean;
}) {
const { refreshUser } = useUser();
const router = useRouter();
return (
<div
@ -80,7 +83,7 @@ export function AssistantGalleryCard({
message: `"${assistant.name}" has been removed from your list.`,
type: "success",
});
router.refresh();
await refreshUser();
} else {
setPopup({
message: `"${assistant.name}" could not be removed from your list.`,
@ -108,7 +111,7 @@ export function AssistantGalleryCard({
message: `"${assistant.name}" has been added to your list.`,
type: "success",
});
router.refresh();
await refreshUser();
} else {
setPopup({
message: `"${assistant.name}" could not be added to your list.`,
@ -136,14 +139,10 @@ export function AssistantGalleryCard({
</div>
);
}
export function AssistantsGallery({
assistants,
user,
}: {
assistants: Persona[];
export function AssistantsGallery() {
const { assistants } = useAssistants();
const { user } = useUser();
user: User | null;
}) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");

View File

@ -12,15 +12,11 @@ export default function WrappedAssistantsGallery({
initiallyToggled,
folders,
openedFolders,
user,
assistants,
}: {
chatSessions: ChatSession[];
folders: Folder[];
initiallyToggled: boolean;
openedFolders?: { [key: number]: boolean };
user: User | null;
assistants: Persona[];
}) {
return (
<SidebarWrapper
@ -29,17 +25,8 @@ export default function WrappedAssistantsGallery({
chatSessions={chatSessions}
folders={folders}
openedFolders={openedFolders}
headerProps={{ user, page: "chat" }}
contentProps={{
assistants: assistants,
user: user,
}}
content={(contentProps) => (
<AssistantsGallery
assistants={contentProps.assistants}
user={contentProps.user}
/>
)}
/>
>
<AssistantsGallery />
</SidebarWrapper>
);
}

View File

@ -4,6 +4,7 @@ import { fetchChatData } from "@/lib/chat/fetchChatData";
import { unstable_noStore as noStore } from "next/cache";
import { redirect } from "next/navigation";
import WrappedAssistantsGallery from "./WrappedAssistantsGallery";
import { AssistantsProvider } from "@/components/context/AssistantsContext";
export default async function GalleryPage({
searchParams,
@ -26,22 +27,28 @@ export default async function GalleryPage({
openedFolders,
shouldShowWelcomeModal,
toggleSidebar,
hasAnyConnectors,
hasImageCompatibleModel,
} = data;
return (
<>
{shouldShowWelcomeModal && <WelcomeModal user={user} />}
<AssistantsProvider
initialAssistants={assistants}
hasAnyConnectors={hasAnyConnectors}
hasImageCompatibleModel={hasImageCompatibleModel}
>
{shouldShowWelcomeModal && <WelcomeModal user={user} />}
<InstantSSRAutoRefresh />
<InstantSSRAutoRefresh />
<WrappedAssistantsGallery
initiallyToggled={toggleSidebar}
chatSessions={chatSessions}
folders={folders}
openedFolders={openedFolders}
user={user}
assistants={assistants}
/>
<WrappedAssistantsGallery
initiallyToggled={toggleSidebar}
chatSessions={chatSessions}
folders={folders}
openedFolders={openedFolders}
/>
</AssistantsProvider>
</>
);
}

View File

@ -16,6 +16,7 @@ import { Bubble } from "@/components/Bubble";
import { useRouter } from "next/navigation";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { Spinner } from "@/components/Spinner";
import { useAssistants } from "@/components/context/AssistantsContext";
interface AssistantSharingModalProps {
assistant: Persona;
@ -32,7 +33,7 @@ export function AssistantSharingModal({
show,
onClose,
}: AssistantSharingModalProps) {
const router = useRouter();
const { refreshAssistants } = useAssistants();
const { popup, setPopup } = usePopup();
const [isUpdating, setIsUpdating] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<MinimalUserSnapshot[]>([]);
@ -54,7 +55,7 @@ export function AssistantSharingModal({
assistant,
selectedUsers.map((user) => user.id)
);
router.refresh();
await refreshAssistants();
const elapsedTime = Date.now() - startTime;
const remainingTime = Math.max(0, 1000 - elapsedTime);
@ -96,7 +97,7 @@ export function AssistantSharingModal({
assistant,
[u.id]
);
router.refresh();
await refreshAssistants();
const elapsedTime = Date.now() - startTime;
const remainingTime = Math.max(0, 1000 - elapsedTime);
@ -138,7 +139,6 @@ export function AssistantSharingModal({
onOutsideClick={onClose}
>
<div>
{isUpdating && <Spinner />}
<p className="text-text-600 text-lg mb-6">
Manage access to this assistant by sharing it with other users.
</p>
@ -225,6 +225,7 @@ export function AssistantSharingModal({
)}
</div>
</Modal>
{isUpdating && <Spinner />}
</>
);
}

View File

@ -55,12 +55,9 @@ import {
} from "@/app/admin/assistants/lib";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { MakePublicAssistantModal } from "@/app/chat/modal/MakePublicAssistantModal";
import {
classifyAssistants,
getUserCreatedAssistants,
orderAssistantsForUser,
} from "@/lib/assistants/utils";
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useUser } from "@/components/user/UserProvider";
function DraggableAssistantListItem(props: any) {
const {
@ -112,6 +109,7 @@ function AssistantListItem({
setPopup: (popupSpec: PopupSpec | null) => void;
isDragging?: boolean;
}) {
const { refreshUser } = useUser();
const router = useRouter();
const [showSharingModal, setShowSharingModal] = useState(false);
@ -206,7 +204,7 @@ function AssistantListItem({
message: `"${assistant.name}" has been removed from your list.`,
type: "success",
});
router.refresh();
await refreshUser();
} else {
setPopup({
message: `"${assistant.name}" could not be removed from your list.`,
@ -229,7 +227,7 @@ function AssistantListItem({
message: `"${assistant.name}" has been added to your list.`,
type: "success",
});
router.refresh();
await refreshUser();
} else {
setPopup({
message: `"${assistant.name}" could not be added to your list.`,
@ -284,32 +282,20 @@ function AssistantListItem({
</>
);
}
export function AssistantsList({
user,
assistants,
}: {
user: User | null;
assistants: Persona[];
}) {
// Define the distinct groups of assistants
const { visibleAssistants, hiddenAssistants } = classifyAssistants(
user,
assistants
);
export function AssistantsList() {
const {
assistants,
ownedButHiddenAssistants,
finalAssistants,
refreshAssistants,
} = useAssistants();
const [currentlyVisibleAssistants, setCurrentlyVisibleAssistants] = useState<
Persona[]
>([]);
const [currentlyVisibleAssistants, setCurrentlyVisibleAssistants] =
useState(finalAssistants);
useEffect(() => {
const orderedAssistants = orderAssistantsForUser(visibleAssistants, user);
setCurrentlyVisibleAssistants(orderedAssistants);
}, [assistants, user]);
const ownedButHiddenAssistants = getUserCreatedAssistants(
user,
hiddenAssistants
);
setCurrentlyVisibleAssistants(finalAssistants);
}, [finalAssistants]);
const allAssistantIds = assistants.map((assistant) =>
assistant.id.toString()
@ -320,6 +306,8 @@ export function AssistantsList({
null
);
const { refreshUser, user } = useUser();
const { popup, setPopup } = usePopup();
const router = useRouter();
const { data: users } = useSWR<MinimalUserSnapshot[]>(
@ -338,18 +326,22 @@ export function AssistantsList({
const { active, over } = event;
if (over && active.id !== over.id) {
setCurrentlyVisibleAssistants((assistants) => {
const oldIndex = assistants.findIndex(
(a) => a.id.toString() === active.id
);
const newIndex = assistants.findIndex(
(a) => a.id.toString() === over.id
);
const newAssistants = arrayMove(assistants, oldIndex, newIndex);
const oldIndex = currentlyVisibleAssistants.findIndex(
(item) => item.id.toString() === active.id
);
const newIndex = currentlyVisibleAssistants.findIndex(
(item) => item.id.toString() === over.id
);
const updatedAssistants = arrayMove(
currentlyVisibleAssistants,
oldIndex,
newIndex
);
updateUserAssistantList(newAssistants.map((a) => a.id));
return newAssistants;
});
setCurrentlyVisibleAssistants(updatedAssistants);
await updateUserAssistantList(updatedAssistants.map((a) => a.id));
await refreshUser();
await refreshAssistants();
}
}
@ -368,7 +360,7 @@ export function AssistantsList({
message: `"${deletingPersona.name}" has been deleted.`,
type: "success",
});
router.refresh();
await refreshUser();
} else {
setPopup({
message: `"${deletingPersona.name}" could not be deleted.`,
@ -389,7 +381,7 @@ export function AssistantsList({
makePublicPersona.id,
newPublicStatus
);
router.refresh();
await refreshAssistants();
}}
/>
)}

View File

@ -3,23 +3,17 @@ import { AssistantsList } from "./AssistantsList";
import SidebarWrapper from "../SidebarWrapper";
import { ChatSession } from "@/app/chat/interfaces";
import { Folder } from "@/app/chat/folders/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { User } from "@/lib/types";
export default function WrappedAssistantsMine({
chatSessions,
initiallyToggled,
folders,
openedFolders,
user,
assistants,
}: {
chatSessions: ChatSession[];
folders: Folder[];
initiallyToggled: boolean;
openedFolders?: { [key: number]: boolean };
user: User | null;
assistants: Persona[];
}) {
return (
<SidebarWrapper
@ -28,17 +22,8 @@ export default function WrappedAssistantsMine({
chatSessions={chatSessions}
folders={folders}
openedFolders={openedFolders}
headerProps={{ user, page: "chat" }}
contentProps={{
assistants: assistants,
user: user,
}}
content={(contentProps) => (
<AssistantsList
assistants={contentProps.assistants}
user={contentProps.user}
/>
)}
/>
>
<AssistantsList />
</SidebarWrapper>
);
}

View File

@ -2,7 +2,6 @@
import SidebarWrapper from "../SidebarWrapper";
import { ChatSession } from "@/app/chat/interfaces";
import { Folder } from "@/app/chat/folders/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { User } from "@/lib/types";
import { AssistantsPageTitle } from "../AssistantsPageTitle";
@ -14,15 +13,11 @@ export default function WrappedPrompts({
initiallyToggled,
folders,
openedFolders,
user,
assistants,
}: {
chatSessions: ChatSession[];
folders: Folder[];
initiallyToggled: boolean;
openedFolders?: { [key: number]: boolean };
user: User | null;
assistants: Persona[];
}) {
const {
data: promptLibrary,
@ -39,24 +34,18 @@ export default function WrappedPrompts({
chatSessions={chatSessions}
folders={folders}
openedFolders={openedFolders}
headerProps={{ user, page: "chat" }}
contentProps={{
assistants: assistants,
user: user,
}}
content={(contentProps) => (
<div className="mx-auto w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar">
<AssistantsPageTitle>Prompt Gallery</AssistantsPageTitle>
<PromptSection
promptLibrary={promptLibrary || []}
isLoading={promptLibraryIsLoading}
error={promptLibraryError}
refreshPrompts={refreshPrompts}
isPublic={false}
centering
/>
</div>
)}
/>
>
<div className="mx-auto w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar">
<AssistantsPageTitle>Prompt Gallery</AssistantsPageTitle>
<PromptSection
promptLibrary={promptLibrary || []}
isLoading={promptLibraryIsLoading}
error={promptLibraryError}
refreshPrompts={refreshPrompts}
isPublic={false}
centering
/>
</div>
</SidebarWrapper>
);
}

View File

@ -4,6 +4,7 @@ import { fetchChatData } from "@/lib/chat/fetchChatData";
import { unstable_noStore as noStore } from "next/cache";
import { redirect } from "next/navigation";
import WrappedAssistantsMine from "./WrappedAssistantsMine";
import { AssistantsProvider } from "@/components/context/AssistantsContext";
export default async function GalleryPage({
searchParams,
@ -21,27 +22,30 @@ export default async function GalleryPage({
const {
user,
chatSessions,
assistants,
folders,
assistants,
openedFolders,
shouldShowWelcomeModal,
toggleSidebar,
hasAnyConnectors,
hasImageCompatibleModel,
} = data;
return (
<>
<AssistantsProvider
initialAssistants={assistants}
hasAnyConnectors={hasAnyConnectors}
hasImageCompatibleModel={hasImageCompatibleModel}
>
{shouldShowWelcomeModal && <WelcomeModal user={user} />}
<InstantSSRAutoRefresh />
<WrappedAssistantsMine
initiallyToggled={toggleSidebar}
chatSessions={chatSessions}
folders={folders}
openedFolders={openedFolders}
user={user}
assistants={assistants}
/>
</>
</AssistantsProvider>
);
}

View File

@ -101,12 +101,9 @@ import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
import { SEARCH_TOOL_NAME } from "./tools/constants";
import { useUser } from "@/components/user/UserProvider";
import { ApiKeyModal } from "@/components/llm/ApiKeyModal";
import {
classifyAssistants,
orderAssistantsForUser,
} from "@/lib/assistants/utils";
import BlurBackground from "./shared_chat_search/BlurBackground";
import { NoAssistantModal } from "@/components/modals/NoAssistantModal";
import { useAssistants } from "@/components/context/AssistantsContext";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@ -128,7 +125,6 @@ export function ChatPage({
chatSessions,
availableSources,
availableDocumentSets,
availableAssistants,
llmProviders,
folders,
openedFolders,
@ -138,9 +134,11 @@ export function ChatPage({
refreshChatSessions,
} = useChatContext();
const { assistants: availableAssistants, finalAssistants } = useAssistants();
const [showApiKeyModal, setShowApiKeyModal] = useState(true);
const { user, refreshUser, isAdmin, isLoadingUser } = useUser();
const { user, isAdmin, isLoadingUser } = useUser();
const existingChatIdRaw = searchParams.get("chatId");
const currentPersonaId = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID);
@ -157,18 +155,6 @@ export function ChatPage({
// Useful for determining which session has been loaded (i.e. still on `new, empty session` or `previous session`)
const loadedIdSessionRef = useRef<string | null>(existingChatSessionId);
// Assistants in order
const { finalAssistants } = useMemo(() => {
const { visibleAssistants, hiddenAssistants: _ } = classifyAssistants(
user,
availableAssistants
);
const finalAssistants = user
? orderAssistantsForUser(visibleAssistants, user)
: visibleAssistants;
return { finalAssistants };
}, [user, availableAssistants]);
const existingChatSessionAssistantId = selectedChatSession?.persona_id;
const [selectedAssistant, setSelectedAssistant] = useState<
Persona | undefined
@ -1833,7 +1819,6 @@ export function ChatPage({
setPopup={setPopup}
setLlmOverride={llmOverrideManager.setGlobalDefault}
defaultModel={user?.preferences.default_model!}
refreshUser={refreshUser}
llmProviders={llmProviders}
onClose={() => setSettingsToggled(false)}
/>
@ -1953,7 +1938,6 @@ export function ChatPage({
: undefined
}
toggleSidebar={toggleSidebar}
user={user}
currentChatSession={selectedChatSession}
/>
)}
@ -2438,7 +2422,6 @@ export function ChatPage({
showDocs={() => setDocumentSelection(true)}
selectedDocuments={selectedDocuments}
// assistant stuff
assistantOptions={finalAssistants}
selectedAssistant={liveAssistant}
setSelectedAssistant={onAssistantChange}
setAlternativeAssistant={setAlternativeAssistant}
@ -2454,7 +2437,6 @@ export function ChatPage({
handleFileUpload={handleImageUpload}
textAreaRef={textAreaRef}
chatSessionId={chatSessionIdRef.current!}
refreshUser={refreshUser}
/>
{enterpriseSettings &&

View File

@ -34,6 +34,7 @@ import { Hoverable } from "@/components/Hoverable";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { ChatState } from "../types";
import UnconfiguredProviderText from "@/components/chat_search/UnconfiguredProviderText";
import { useAssistants } from "@/components/context/AssistantsContext";
const MAX_INPUT_HEIGHT = 200;
@ -52,7 +53,6 @@ export function ChatInputBar({
// assistants
selectedAssistant,
assistantOptions,
setSelectedAssistant,
setAlternativeAssistant,
@ -63,7 +63,6 @@ export function ChatInputBar({
alternativeAssistant,
chatSessionId,
inputPrompts,
refreshUser,
}: {
showConfigureAPIKey: () => void;
openModelSettings: () => void;
@ -71,7 +70,6 @@ export function ChatInputBar({
stopGenerating: () => void;
showDocs: () => void;
selectedDocuments: DanswerDocument[];
assistantOptions: Persona[];
setAlternativeAssistant: (alternativeAssistant: Persona | null) => void;
setSelectedAssistant: (assistant: Persona) => void;
inputPrompts: InputPrompt[];
@ -87,7 +85,6 @@ export function ChatInputBar({
handleFileUpload: (files: File[]) => void;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
chatSessionId?: string;
refreshUser: () => void;
}) {
useEffect(() => {
const textarea = textAreaRef.current;
@ -118,6 +115,7 @@ export function ChatInputBar({
};
const settings = useContext(SettingsContext);
const { finalAssistants: assistantOptions } = useAssistants();
const { llmProviders } = useChatContext();
const [_, llmName] = getFinalLLM(llmProviders, selectedAssistant, null);
@ -527,14 +525,12 @@ export function ChatInputBar({
removePadding
content={(close) => (
<AssistantsTab
availableAssistants={assistantOptions}
llmProviders={llmProviders}
selectedAssistant={selectedAssistant}
onSelect={(assistant) => {
setSelectedAssistant(assistant);
close();
}}
refreshUser={refreshUser}
/>
)}
flexPriority="shrink"

View File

@ -8,6 +8,7 @@ import { destructureValue, structureValue } from "@/lib/llm/utils";
import { setUserDefaultModel } from "@/lib/users/UserSettings";
import { useRouter } from "next/navigation";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { useUser } from "@/components/user/UserProvider";
export function SetDefaultModelModal({
setPopup,
@ -15,15 +16,14 @@ export function SetDefaultModelModal({
onClose,
setLlmOverride,
defaultModel,
refreshUser,
}: {
setPopup: (popupSpec: PopupSpec | null) => void;
llmProviders: LLMProviderDescriptor[];
setLlmOverride: Dispatch<SetStateAction<LlmOverride>>;
onClose: () => void;
defaultModel: string | null;
refreshUser: () => void;
}) {
const { refreshUser } = useUser();
const containerRef = useRef<HTMLDivElement>(null);
const messageRef = useRef<HTMLDivElement>(null);

View File

@ -16,25 +16,29 @@ import {
import { Persona } from "@/app/admin/assistants/interfaces";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { getFinalLLM } from "@/lib/llm/utils";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { updateUserAssistantList } from "@/lib/assistants/updateAssistantPreferences";
import { DraggableAssistantCard } from "@/components/assistants/AssistantCards";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useUser } from "@/components/user/UserProvider";
export function AssistantsTab({
selectedAssistant,
availableAssistants,
llmProviders,
onSelect,
refreshUser,
}: {
selectedAssistant: Persona;
availableAssistants: Persona[];
llmProviders: LLMProviderDescriptor[];
onSelect: (assistant: Persona) => void;
refreshUser: () => void;
}) {
const { refreshUser } = useUser();
const [_, llmName] = getFinalLLM(llmProviders, null, null);
const [assistants, setAssistants] = useState(availableAssistants);
const { finalAssistants, refreshAssistants } = useAssistants();
const [assistants, setAssistants] = useState(finalAssistants);
useEffect(() => {
setAssistants(finalAssistants);
}, [finalAssistants]);
const sensors = useSensors(
useSensor(PointerSensor),
@ -57,7 +61,8 @@ export function AssistantsTab({
setAssistants(updatedAssistants);
await updateUserAssistantList(updatedAssistants.map((a) => a.id));
refreshUser();
await refreshUser();
await refreshAssistants();
}
}

View File

@ -5,6 +5,7 @@ import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrap
import { ChatProvider } from "@/components/context/ChatContext";
import { fetchChatData } from "@/lib/chat/fetchChatData";
import WrappedChat from "./WrappedChat";
import { AssistantsProvider } from "@/components/context/AssistantsContext";
export default async function Page({
searchParams,
@ -24,7 +25,6 @@ export default async function Page({
chatSessions,
availableSources,
documentSets,
assistants,
tags,
llmProviders,
folders,
@ -32,31 +32,38 @@ export default async function Page({
openedFolders,
defaultAssistantId,
shouldShowWelcomeModal,
assistants,
userInputPrompts,
hasAnyConnectors,
hasImageCompatibleModel,
} = data;
return (
<>
<InstantSSRAutoRefresh />
{shouldShowWelcomeModal && <WelcomeModal user={user} />}
<ChatProvider
value={{
chatSessions,
availableSources,
availableDocumentSets: documentSets,
availableAssistants: assistants,
availableTags: tags,
llmProviders,
folders,
openedFolders,
userInputPrompts,
shouldShowWelcomeModal,
defaultAssistantId,
}}
<AssistantsProvider
initialAssistants={assistants}
hasAnyConnectors={hasAnyConnectors}
hasImageCompatibleModel={hasImageCompatibleModel}
>
<WrappedChat initiallyToggled={toggleSidebar} />
</ChatProvider>
<ChatProvider
value={{
chatSessions,
availableSources,
availableDocumentSets: documentSets,
availableTags: tags,
llmProviders,
folders,
openedFolders,
userInputPrompts,
shouldShowWelcomeModal,
defaultAssistantId,
}}
>
<WrappedChat initiallyToggled={toggleSidebar} />
</ChatProvider>
</AssistantsProvider>
</>
);
}

View File

@ -67,7 +67,7 @@ export default async function Page({ params }: { params: { chatId: string } }) {
return (
<div>
<div className="absolute top-0 z-40 w-full">
<FunctionalHeader page="shared" user={user} />
<FunctionalHeader page="shared" />
</div>
<div className="flex relative bg-background text-default overflow-hidden pt-16 h-screen">

View File

@ -16,14 +16,7 @@ export default async function GalleryPage({
redirect(data.redirect);
}
const {
user,
chatSessions,
assistants,
folders,
openedFolders,
toggleSidebar,
} = data;
const { chatSessions, folders, openedFolders, toggleSidebar } = data;
return (
<WrappedPrompts
@ -31,8 +24,6 @@ export default async function GalleryPage({
chatSessions={chatSessions}
folders={folders}
openedFolders={openedFolders}
user={user}
assistants={assistants}
/>
);
}

View File

@ -36,6 +36,7 @@ import WrappedSearch from "./WrappedSearch";
import { SearchProvider } from "@/components/context/SearchContext";
import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs";
import { LLMProviderDescriptor } from "../admin/configuration/llm/interfaces";
import { AssistantsProvider } from "@/components/context/AssistantsContext";
import { headers } from "next/headers";
export default async function Home({
@ -193,36 +194,39 @@ export default async function Home({
<HealthCheckBanner />
{shouldShowWelcomeModal && <WelcomeModal user={user} />}
<InstantSSRAutoRefresh />
{shouldDisplayNoSourcesModal && <NoSourcesModal />}
{shouldDisplaySourcesIncompleteModal && (
<NoCompleteSourcesModal ccPairs={ccPairs} />
)}
{/* ChatPopup is a custom popup that displays a admin-specified message on initial user visit.
Only used in the EE version of the app. */}
<ChatPopup />
<SearchProvider
value={{
querySessions,
ccPairs,
documentSets,
assistants,
tags,
agenticSearchEnabled,
disabledAgentic: DISABLE_LLM_DOC_RELEVANCE,
initiallyToggled: toggleSidebar,
shouldShowWelcomeModal,
shouldDisplayNoSources: shouldDisplayNoSourcesModal,
}}
<AssistantsProvider
initialAssistants={assistants}
hasAnyConnectors={hasAnyConnectors}
hasImageCompatibleModel={false}
>
<WrappedSearch
initiallyToggled={toggleSidebar}
searchTypeDefault={searchTypeDefault}
/>
</SearchProvider>
<SearchProvider
value={{
querySessions,
ccPairs,
documentSets,
assistants,
tags,
agenticSearchEnabled,
disabledAgentic: DISABLE_LLM_DOC_RELEVANCE,
initiallyToggled: toggleSidebar,
shouldShowWelcomeModal,
shouldDisplayNoSources: shouldDisplayNoSourcesModal,
}}
>
<WrappedSearch
initiallyToggled={toggleSidebar}
searchTypeDefault={searchTypeDefault}
/>
</SearchProvider>
</AssistantsProvider>
s
</>
);
}

View File

@ -4,19 +4,20 @@ import { useState, useRef, useContext, useEffect, useMemo } from "react";
import { FiLogOut } from "react-icons/fi";
import Link from "next/link";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { User, UserRole } from "@/lib/types";
import { UserRole } from "@/lib/types";
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 { BellIcon, LightSettingsIcon } from "./icons/icons";
import { pageType } from "@/app/chat/sessionSidebar/types";
import { NavigationItem } from "@/app/admin/settings/interfaces";
import { NavigationItem, Notification } from "@/app/admin/settings/interfaces";
import DynamicFaIcon, { preloadIcons } from "./icons/DynamicFaIcon";
import { useUser } from "./user/UserProvider";
import { usePaidEnterpriseFeaturesEnabled } from "./settings/usePaidEnterpriseFeaturesEnabled";
import { Notifications } from "./chat_search/Notifications";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
interface DropdownOptionProps {
href?: string;
@ -55,24 +56,25 @@ const DropdownOption: React.FC<DropdownOptionProps> = ({
}
};
export function UserDropdown({
user,
page,
}: {
user: User | null;
page?: pageType;
}) {
export function UserDropdown({ page }: { page?: pageType }) {
const { user } = useUser();
const [userInfoVisible, setUserInfoVisible] = useState(false);
const userInfoRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [showNotifications, setShowNotifications] = useState(false);
const combinedSettings = useContext(SettingsContext);
const customNavItems: NavigationItem[] = useMemo(
() => combinedSettings?.enterpriseSettings?.custom_nav_items || [],
[combinedSettings]
);
const {
data: notifications,
error,
mutate: refreshNotifications,
} = useSWR<Notification[]>("/api/notifications", errorHandlingFetcher);
useEffect(() => {
const iconNames = customNavItems
@ -110,15 +112,20 @@ export function UserDropdown({
const showLogout =
user && !checkUserIsNoAuthUser(user.id) && !LOGOUT_DISABLED;
const onOpenChange = (open: boolean) => {
setUserInfoVisible(open);
setShowNotifications(false);
};
return (
<div className="group relative" ref={userInfoRef}>
<Popover
open={userInfoVisible}
onOpenChange={setUserInfoVisible}
onOpenChange={onOpenChange}
content={
<div
onClick={() => setUserInfoVisible(!userInfoVisible)}
className="flex cursor-pointer"
className="flex relative cursor-pointer"
>
<div
className="
@ -138,6 +145,9 @@ export function UserDropdown({
>
{user && user.email ? user.email[0].toUpperCase() : "A"}
</div>
{notifications && notifications.length > 0 && (
<div className="absolute right-0 top-0 w-2 h-2 bg-red-500 rounded-full"></div>
)}
</div>
}
popover={
@ -161,14 +171,22 @@ export function UserDropdown({
overscroll-contain
`}
>
{customNavItems.map((item, i) => (
<DropdownOption
key={i}
href={item.link}
icon={
item.svg_logo ? (
<div
className="
{page != "admin" && showNotifications ? (
<Notifications
navigateToDropdown={() => setShowNotifications(false)}
notifications={notifications || []}
refreshNotifications={refreshNotifications}
/>
) : (
<>
{customNavItems.map((item, i) => (
<DropdownOption
key={i}
href={item.link}
icon={
item.svg_logo ? (
<div
className="
h-4
w-4
my-auto
@ -178,57 +196,72 @@ export function UserDropdown({
items-center
justify-center
"
aria-label={item.title}
>
<svg
viewBox="0 0 24 24"
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
dangerouslySetInnerHTML={{ __html: item.svg_logo }}
/>
</div>
) : (
<DynamicFaIcon
name={item.icon!}
className="h-4 w-4 my-auto mr-2"
aria-label={item.title}
>
<svg
viewBox="0 0 24 24"
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
dangerouslySetInnerHTML={{ __html: item.svg_logo }}
/>
</div>
) : (
<DynamicFaIcon
name={item.icon!}
className="h-4 w-4 my-auto mr-2"
/>
)
}
label={item.title}
openInNewTab
/>
))}
{showAdminPanel ? (
<DropdownOption
href="/admin/indexing/status"
icon={
<LightSettingsIcon className="h-5 w-5 my-auto mr-2" />
}
label="Admin Panel"
/>
) : (
showCuratorPanel && (
<DropdownOption
href="/admin/indexing/status"
icon={
<LightSettingsIcon className="h-5 w-5 my-auto mr-2" />
}
label="Curator Panel"
/>
)
}
label={item.title}
openInNewTab
/>
))}
)}
{showAdminPanel ? (
<DropdownOption
href="/admin/indexing/status"
icon={<LightSettingsIcon className="h-5 w-5 my-auto mr-2" />}
label="Admin Panel"
/>
) : (
showCuratorPanel && (
<DropdownOption
href="/admin/indexing/status"
icon={<LightSettingsIcon className="h-5 w-5 my-auto mr-2" />}
label="Curator Panel"
onClick={() => {
setUserInfoVisible(true);
setShowNotifications(true);
}}
icon={<BellIcon className="h-5 w-5 my-auto mr-2" />}
label={`Notifications ${notifications && notifications.length > 0 ? `(${notifications.length})` : ""}`}
/>
)
)}
{showLogout &&
(showCuratorPanel ||
showAdminPanel ||
customNavItems.length > 0) && (
<div className="border-t border-border my-1" />
)}
{showLogout &&
(showCuratorPanel ||
showAdminPanel ||
customNavItems.length > 0) && (
<div className="border-t border-border my-1" />
)}
{showLogout && (
<DropdownOption
onClick={handleLogout}
icon={<FiLogOut className="my-auto mr-2 text-lg" />}
label="Log out"
/>
{showLogout && (
<DropdownOption
onClick={handleLogout}
icon={<FiLogOut className="my-auto mr-2 text-lg" />}
label="Log out"
/>
)}
</>
)}
</div>
}

View File

@ -418,7 +418,7 @@ export function ClientLayout({
</div>
<div className="pb-8 relative h-full overflow-y-auto w-full">
<div className="fixed bg-background left-0 gap-x-4 mb-8 px-4 py-2 w-full items-center flex justify-end">
<UserDropdown user={user} />
<UserDropdown />
</div>
<div className="pt-20 flex overflow-y-auto h-full px-4 md:px-12">
{children}

View File

@ -11,9 +11,9 @@ import { pageType } from "@/app/chat/sessionSidebar/types";
import { useRouter } from "next/navigation";
import { ChatBanner } from "@/app/chat/ChatBanner";
import LogoType from "../header/LogoType";
import { useUser } from "../user/UserProvider";
export default function FunctionalHeader({
user,
page,
currentChatSession,
setSharingModalVisible,
@ -23,7 +23,6 @@ export default function FunctionalHeader({
}: {
reset?: () => void;
page: pageType;
user: User | null;
sidebarToggled?: boolean;
currentChatSession?: ChatSession | null | undefined;
setSharingModalVisible?: (value: SetStateAction<boolean>) => void;
@ -109,7 +108,7 @@ export default function FunctionalHeader({
)}
<div className="mobile:hidden flex my-auto">
<UserDropdown user={user} />
<UserDropdown />
</div>
<Link
className="desktop:hidden my-auto"

View File

@ -0,0 +1,231 @@
import React, { useEffect, useState } from "react";
import useSWR from "swr";
import { Persona } from "@/app/admin/assistants/interfaces";
import {
Notification,
NotificationType,
} from "@/app/admin/settings/interfaces";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { addAssistantToList } from "@/lib/assistants/updateAssistantPreferences";
import { useAssistants } from "../context/AssistantsContext";
import { useUser } from "../user/UserProvider";
import { XIcon } from "../icons/icons";
import { Spinner } from "@phosphor-icons/react";
export const Notifications = ({
notifications,
refreshNotifications,
navigateToDropdown,
}: {
notifications: Notification[];
refreshNotifications: () => void;
navigateToDropdown: () => void;
}) => {
const [showDropdown, setShowDropdown] = useState(false);
const { refreshAssistants } = useAssistants();
const { refreshUser } = useUser();
const [personas, setPersonas] = useState<Record<number, Persona> | undefined>(
undefined
);
useEffect(() => {
const fetchPersonas = async () => {
if (notifications) {
const personaIds = notifications
.filter(
(n) =>
n.notif_type.toLowerCase() === "persona_shared" &&
n.additional_data?.persona_id !== undefined
)
.map((n) => n.additional_data!.persona_id!);
if (personaIds.length > 0) {
const queryParams = personaIds
.map((id) => `persona_ids=${id}`)
.join("&");
try {
const response = await fetch(`/api/persona?${queryParams}`);
if (!response.ok) {
throw new Error(
`Error fetching personas: ${response.statusText}`
);
}
const personasData: Persona[] = await response.json();
setPersonas(
personasData.reduce(
(acc, persona) => {
acc[persona.id] = persona;
return acc;
},
{} as Record<number, Persona>
)
);
} catch (err) {
console.error("Failed to fetch personas:", err);
}
}
}
};
fetchPersonas();
}, [notifications]);
const dismissNotification = async (notificationId: number) => {
try {
await fetch(`/api/notifications/${notificationId}/dismiss`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
refreshNotifications();
} catch (error) {
console.error("Error dismissing notification:", error);
}
};
const handleAssistantShareAcceptance = async (
notification: Notification,
persona: Persona
) => {
addAssistantToList(persona.id);
await dismissNotification(notification.id);
await refreshUser();
await refreshAssistants();
};
const sortedNotifications = notifications
? notifications
.filter((notification) => {
const personaId = notification.additional_data?.persona_id;
return (
personaId !== undefined &&
personas &&
personas[personaId] !== undefined
);
})
.sort(
(a, b) =>
new Date(b.time_created).getTime() -
new Date(a.time_created).getTime()
)
: [];
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
showDropdown &&
!(event.target as Element).closest(".notification-dropdown")
) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showDropdown]);
return (
<div className="w-full">
<button
onClick={navigateToDropdown}
className="absolute right-2 text-background-600 hover:text-background-900 transition-colors duration-150 ease-in-out rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Back"
>
<XIcon className="w-5 h-5" />
</button>
{notifications && notifications.length > 0 ? (
sortedNotifications.length > 0 && personas ? (
sortedNotifications
.filter(
(notification) =>
notification.notif_type === NotificationType.PERSONA_SHARED
)
.map((notification) => {
const persona = notification.additional_data?.persona_id
? personas[notification.additional_data.persona_id]
: null;
return (
<div
key={notification.id}
className="w-72 px-4 py-3 border-b last:border-b-0 hover:bg-gray-50 transition duration-150 ease-in-out"
>
<div className="flex items-start">
{persona && (
<div className="mt-2 flex-shrink-0 mr-3">
<AssistantIcon assistant={persona} size="small" />
</div>
)}
<div className="flex-grow">
<p className="font-semibold text-sm text-gray-800">
New Assistant Shared: {persona?.name}
</p>
{persona?.description && (
<p className="text-xs text-gray-600 mt-1">
{persona.description}
</p>
)}
{persona && (
<div className="mt-2">
{persona.tools.length > 0 && (
<p className="text-xs text-gray-500">
Tools:{" "}
{persona.tools
.map((tool) => tool.name)
.join(", ")}
</p>
)}
{persona.document_sets.length > 0 && (
<p className="text-xs text-gray-500">
Document Sets:{" "}
{persona.document_sets
.map((set) => set.name)
.join(", ")}
</p>
)}
{persona.llm_model_version_override && (
<p className="text-xs text-gray-500">
Model: {persona.llm_model_version_override}
</p>
)}
</div>
)}
</div>
</div>
<div className="flex justify-end mt-2 space-x-2">
<button
onClick={() =>
handleAssistantShareAcceptance(notification, persona!)
}
className="px-3 py-1 text-sm font-medium text-blue-600 hover:text-blue-800 transition duration-150 ease-in-out"
>
Accept
</button>
<button
onClick={() => dismissNotification(notification.id)}
className="px-3 py-1 text-sm font-medium text-gray-600 hover:text-gray-800 transition duration-150 ease-in-out"
>
Dismiss
</button>
</div>
</div>
);
})
) : (
<div className="flex h-20 justify-center items-center w-72">
<Spinner size={20} />
</div>
)
) : (
<div className="px-4 py-3 text-center text-gray-600">
No new notifications
</div>
)}
</div>
);
};

View File

@ -0,0 +1,119 @@
"use client";
import React, { createContext, useState, useContext, useMemo } from "react";
import { Persona } from "@/app/admin/assistants/interfaces";
import {
classifyAssistants,
orderAssistantsForUser,
getUserCreatedAssistants,
} from "@/lib/assistants/utils";
import { useUser } from "../user/UserProvider";
interface AssistantsContextProps {
assistants: Persona[];
visibleAssistants: Persona[];
hiddenAssistants: Persona[];
finalAssistants: Persona[];
ownedButHiddenAssistants: Persona[];
refreshAssistants: () => Promise<void>;
}
const AssistantsContext = createContext<AssistantsContextProps | undefined>(
undefined
);
export const AssistantsProvider: React.FC<{
children: React.ReactNode;
initialAssistants: Persona[];
hasAnyConnectors: boolean;
hasImageCompatibleModel: boolean;
}> = ({
children,
initialAssistants,
hasAnyConnectors,
hasImageCompatibleModel,
}) => {
const [assistants, setAssistants] = useState<Persona[]>(
initialAssistants || []
);
const { user } = useUser();
const refreshAssistants = async () => {
try {
const response = await fetch("/api/persona", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) throw new Error("Failed to fetch assistants");
let assistants: Persona[] = await response.json();
if (!hasImageCompatibleModel) {
assistants = assistants.filter(
(assistant) =>
!assistant.tools.some(
(tool) => tool.in_code_tool_id === "ImageGenerationTool"
)
);
}
if (!hasAnyConnectors) {
assistants = assistants.filter(
(assistant) => assistant.num_chunks === 0
);
}
setAssistants(assistants);
} catch (error) {
console.error("Error refreshing assistants:", error);
}
};
const {
visibleAssistants,
hiddenAssistants,
finalAssistants,
ownedButHiddenAssistants,
} = useMemo(() => {
const { visibleAssistants, hiddenAssistants } = classifyAssistants(
user,
assistants
);
const finalAssistants = user
? orderAssistantsForUser(visibleAssistants, user)
: visibleAssistants;
const ownedButHiddenAssistants = getUserCreatedAssistants(
user,
hiddenAssistants
);
return {
visibleAssistants,
hiddenAssistants,
finalAssistants,
ownedButHiddenAssistants,
};
}, [user, assistants]);
return (
<AssistantsContext.Provider
value={{
assistants,
visibleAssistants,
hiddenAssistants,
finalAssistants,
ownedButHiddenAssistants,
refreshAssistants,
}}
>
{children}
</AssistantsContext.Provider>
);
};
export const useAssistants = (): AssistantsContextProps => {
const context = useContext(AssistantsContext);
if (!context) {
throw new Error("useAssistants must be used within an AssistantsProvider");
}
return context;
};

View File

@ -7,12 +7,12 @@ import { Persona } from "@/app/admin/assistants/interfaces";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { Folder } from "@/app/chat/folders/interfaces";
import { InputPrompt } from "@/app/admin/prompt-library/interfaces";
import { personaComparator } from "@/app/admin/assistants/lib";
interface ChatContextProps {
chatSessions: ChatSession[];
availableSources: ValidSources[];
availableDocumentSets: DocumentSet[];
availableAssistants: Persona[];
availableTags: Tag[];
llmProviders: LLMProviderDescriptor[];
folders: Folder[];
@ -29,7 +29,10 @@ const ChatContext = createContext<ChatContextProps | undefined>(undefined);
// We use Omit to exclude 'refreshChatSessions' from the value prop type
// because we're defining it within the component
export const ChatProvider: React.FC<{
value: Omit<ChatContextProps, "refreshChatSessions">;
value: Omit<
ChatContextProps,
"refreshChatSessions" | "refreshAvailableAssistants"
>;
children: React.ReactNode;
}> = ({ value, children }) => {
const [chatSessions, setChatSessions] = useState(value?.chatSessions || []);
@ -47,7 +50,11 @@ export const ChatProvider: React.FC<{
return (
<ChatContext.Provider
value={{ ...value, chatSessions, refreshChatSessions }}
value={{
...value,
chatSessions,
refreshChatSessions,
}}
>
{children}
</ChatContext.Provider>

View File

@ -959,6 +959,29 @@ export const SearchIcon = ({
);
};
export const BellIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return (
<svg
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fill-rule="evenodd"
d="M12 1.25A7.75 7.75 0 0 0 4.25 9v.704a3.53 3.53 0 0 1-.593 1.958L2.51 13.385c-1.334 2-.316 4.718 2.003 5.35c.755.206 1.517.38 2.284.523l.002.005C7.567 21.315 9.622 22.75 12 22.75s4.433-1.435 5.202-3.487l.002-.005a28.472 28.472 0 0 0 2.284-.523c2.319-.632 3.337-3.35 2.003-5.35l-1.148-1.723a3.53 3.53 0 0 1-.593-1.958V9A7.75 7.75 0 0 0 12 1.25Zm3.376 18.287a28.46 28.46 0 0 1-6.753 0c.711 1.021 1.948 1.713 3.377 1.713c1.429 0 2.665-.692 3.376-1.713ZM5.75 9a6.25 6.25 0 1 1 12.5 0v.704c0 .993.294 1.964.845 2.79l1.148 1.723a2.02 2.02 0 0 1-1.15 3.071a26.96 26.96 0 0 1-14.187 0a2.021 2.021 0 0 1-1.15-3.07l1.15-1.724a5.03 5.03 0 0 0 .844-2.79V9Z"
clip-rule="evenodd"
/>
</svg>
);
};
export const LightSettingsIcon = ({
size = 16,
className = defaultTailwindCSS,

View File

@ -709,7 +709,6 @@ export const SearchSection = ({
reset={() => setQuery("")}
toggleSidebar={toggleSidebar}
page="search"
user={user}
/>
<div className="w-full flex">
<div

View File

@ -47,6 +47,8 @@ interface FetchChatDataResult {
finalDocumentSidebarInitialWidth?: number;
shouldShowWelcomeModal: boolean;
userInputPrompts: InputPrompt[];
hasAnyConnectors: boolean;
hasImageCompatibleModel: boolean;
}
export async function fetchChatData(searchParams: {
@ -251,5 +253,7 @@ export async function fetchChatData(searchParams: {
toggleSidebar,
shouldShowWelcomeModal,
userInputPrompts,
hasAnyConnectors,
hasImageCompatibleModel,
};
}