Allow users to share assistants (#2434)

* enable assistant sharing

* functional

* remove logs

* revert ports

* remove accidental update

* minor updates to copy

* update formatting

* update for merge queue
This commit is contained in:
pablodanswer
2024-09-16 18:35:29 -07:00
committed by GitHub
parent 7ba829a585
commit 7f7559e3d2
11 changed files with 238 additions and 49 deletions

View File

@ -0,0 +1,38 @@
"""server default chosen assistants
Revision ID: 35e6853a51d5
Revises: c99d76fcd298
Create Date: 2024-09-13 13:20:32.885317
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "35e6853a51d5"
down_revision = "c99d76fcd298"
branch_labels = None
depends_on = None
DEFAULT_ASSISTANTS = [-2, -1, 0]
def upgrade() -> None:
op.alter_column(
"user",
"chosen_assistants",
type_=postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text(f"'{DEFAULT_ASSISTANTS}'::jsonb"),
)
def downgrade() -> None:
op.alter_column(
"user",
"chosen_assistants",
type_=postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
server_default=None,
)

View File

@ -122,7 +122,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
# if specified, controls the assistants that are shown to the user + their order # if specified, controls the assistants that are shown to the user + their order
# if not specified, all assistants are shown # if not specified, all assistants are shown
chosen_assistants: Mapped[list[int]] = mapped_column( chosen_assistants: Mapped[list[int]] = mapped_column(
postgresql.JSONB(), nullable=True postgresql.JSONB(), nullable=False, default=[-2, -1, 0]
) )
oidc_expiry: Mapped[datetime.datetime] = mapped_column( oidc_expiry: Mapped[datetime.datetime] = mapped_column(

View File

@ -210,6 +210,22 @@ def update_persona_shared_users(
) )
def update_persona_public_status(
persona_id: int,
is_public: bool,
db_session: Session,
user: User | None,
) -> None:
persona = fetch_persona_by_id(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if user and user.role != UserRole.ADMIN and persona.user_id != user.id:
raise ValueError("You don't have permission to modify this persona")
persona.is_public = is_public
db_session.commit()
def get_prompts( def get_prompts(
user_id: UUID | None, user_id: UUID | None,
db_session: Session, db_session: Session,
@ -551,6 +567,7 @@ def update_persona_visibility(
persona = fetch_persona_by_id( persona = fetch_persona_by_id(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True db_session=db_session, persona_id=persona_id, user=user, get_editable=True
) )
persona.is_visible = is_visible persona.is_visible = is_visible
db_session.commit() db_session.commit()

View File

@ -3,6 +3,7 @@ from uuid import UUID
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import Depends from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query from fastapi import Query
from fastapi import UploadFile from fastapi import UploadFile
from pydantic import BaseModel from pydantic import BaseModel
@ -20,6 +21,7 @@ from danswer.db.persona import get_personas
from danswer.db.persona import mark_persona_as_deleted from danswer.db.persona import mark_persona_as_deleted
from danswer.db.persona import mark_persona_as_not_deleted from danswer.db.persona import mark_persona_as_not_deleted
from danswer.db.persona import update_all_personas_display_priority from danswer.db.persona import update_all_personas_display_priority
from danswer.db.persona import update_persona_public_status
from danswer.db.persona import update_persona_shared_users from danswer.db.persona import update_persona_shared_users
from danswer.db.persona import update_persona_visibility from danswer.db.persona import update_persona_visibility
from danswer.file_store.file_store import get_default_file_store from danswer.file_store.file_store import get_default_file_store
@ -43,6 +45,10 @@ class IsVisibleRequest(BaseModel):
is_visible: bool is_visible: bool
class IsPublicRequest(BaseModel):
is_public: bool
@admin_router.patch("/{persona_id}/visible") @admin_router.patch("/{persona_id}/visible")
def patch_persona_visibility( def patch_persona_visibility(
persona_id: int, persona_id: int,
@ -58,6 +64,25 @@ def patch_persona_visibility(
) )
@basic_router.patch("/{persona_id}/public")
def patch_user_presona_public_status(
persona_id: int,
is_public_request: IsPublicRequest,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
try:
update_persona_public_status(
persona_id=persona_id,
is_public=is_public_request.is_public,
db_session=db_session,
user=user,
)
except ValueError as e:
logger.exception("Failed to update persona public status")
raise HTTPException(status_code=403, detail=str(e))
@admin_router.put("/display-priority") @admin_router.put("/display-priority")
def patch_persona_display_priority( def patch_persona_display_priority(
display_priority_request: DisplayPriorityRequest, display_priority_request: DisplayPriorityRequest,

View File

@ -12,6 +12,7 @@ command=python danswer/background/update.py
redirect_stderr=true redirect_stderr=true
autorestart=true autorestart=true
# Background jobs that must be run async due to long time to completion # Background jobs that must be run async due to long time to completion
# NOTE: due to an issue with Celery + SQLAlchemy # NOTE: due to an issue with Celery + SQLAlchemy
# (https://github.com/celery/celery/issues/7007#issuecomment-1740139367) # (https://github.com/celery/celery/issues/7007#issuecomment-1740139367)
@ -37,11 +38,9 @@ autorestart=true
# Job scheduler for periodic tasks # Job scheduler for periodic tasks
[program:celery_beat] [program:celery_beat]
command=celery -A danswer.background.celery.celery_run:celery_app beat command=celery -A danswer.background.celery.celery_run:celery_app beat
--loglevel=INFO
--logfile=/var/log/celery_beat_supervisor.log --logfile=/var/log/celery_beat_supervisor.log
environment=LOG_FILE_NAME=celery_beat environment=LOG_FILE_NAME=celery_beat
redirect_stderr=true redirect_stderr=true
autorestart=true
# Listens for Slack messages and responds with answers # Listens for Slack messages and responds with answers
# for all channels that the DanswerBot has been added to. # for all channels that the DanswerBot has been added to.

View File

@ -8,7 +8,11 @@ import { usePopup } from "@/components/admin/connectors/Popup";
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect } from "react";
import { UniqueIdentifier } from "@dnd-kit/core"; import { UniqueIdentifier } from "@dnd-kit/core";
import { DraggableTable } from "@/components/table/DraggableTable"; import { DraggableTable } from "@/components/table/DraggableTable";
import { deletePersona, personaComparator } from "./lib"; import {
deletePersona,
personaComparator,
togglePersonaVisibility,
} from "./lib";
import { FiEdit2 } from "react-icons/fi"; import { FiEdit2 } from "react-icons/fi";
import { TrashIcon } from "@/components/icons/icons"; import { TrashIcon } from "@/components/icons/icons";
import { getCurrentUser } from "@/lib/user"; import { getCurrentUser } from "@/lib/user";
@ -31,22 +35,6 @@ function PersonaTypeDisplay({ persona }: { persona: Persona }) {
return <Text>Personal {persona.owner && <>({persona.owner.email})</>}</Text>; return <Text>Personal {persona.owner && <>({persona.owner.email})</>}</Text>;
} }
const togglePersonaVisibility = async (
personaId: number,
isVisible: boolean
) => {
const response = await fetch(`/api/admin/persona/${personaId}/visible`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
is_visible: !isVisible,
}),
});
return response;
};
export function PersonasTable({ export function PersonasTable({
allPersonas, allPersonas,
editablePersonas, editablePersonas,

View File

@ -320,6 +320,38 @@ export function personaComparator(a: Persona, b: Persona) {
return closerToZeroNegativesFirstComparator(a.id, b.id); return closerToZeroNegativesFirstComparator(a.id, b.id);
} }
export const togglePersonaVisibility = async (
personaId: number,
isVisible: boolean
) => {
const response = await fetch(`/api/persona/${personaId}/visible`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
is_visible: !isVisible,
}),
});
return response;
};
export const togglePersonaPublicStatus = async (
personaId: number,
isPublic: boolean
) => {
const response = await fetch(`/api/persona/${personaId}/public`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
is_public: isPublic,
}),
});
return response;
};
export function checkPersonaRequiresImageGeneration(persona: Persona) { export function checkPersonaRequiresImageGeneration(persona: Persona) {
for (const tool of persona.tools) { for (const tool of persona.tools) {
if (tool.name === "ImageGenerationTool") { if (tool.name === "ImageGenerationTool") {

View File

@ -1,14 +1,7 @@
import { User } from "@/lib/types"; import { User } from "@/lib/types";
import { Persona } from "../admin/assistants/interfaces"; import { Persona } from "../admin/assistants/interfaces";
import { checkUserOwnsAssistant } from "@/lib/assistants/checkOwnership"; import { checkUserOwnsAssistant } from "@/lib/assistants/checkOwnership";
import { import { FiLock, FiUnlock } from "react-icons/fi";
FiImage,
FiLock,
FiMoreHorizontal,
FiSearch,
FiUnlock,
} from "react-icons/fi";
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
export function AssistantSharedStatusDisplay({ export function AssistantSharedStatusDisplay({
assistant, assistant,
@ -56,20 +49,6 @@ export function AssistantSharedStatusDisplay({
)} )}
</div> </div>
)} )}
<div className="relative mt-4 text-xs flex text-subtle">
<span className="font-medium">Powers:</span>{" "}
{assistant.tools.length == 0 ? (
<p className="ml-2">None</p>
) : (
assistant.tools.map((tool, ind) => {
if (tool.name === "SearchTool") {
return <FiSearch key={ind} className="ml-1 h-3 w-3 my-auto" />;
} else if (tool.name === "ImageGenerationTool") {
return <FiImage key={ind} className="ml-1 h-3 w-3 my-auto" />;
}
})
)}
</div>
</div> </div>
); );
} }

View File

@ -6,11 +6,15 @@ import { Persona } from "@/app/admin/assistants/interfaces";
import { Divider, Text } from "@tremor/react"; import { Divider, Text } from "@tremor/react";
import { import {
FiEdit2, FiEdit2,
FiFigma,
FiMenu, FiMenu,
FiMinus,
FiMoreHorizontal, FiMoreHorizontal,
FiPlus, FiPlus,
FiSearch, FiSearch,
FiShare,
FiShare2, FiShare2,
FiToggleLeft,
FiTrash, FiTrash,
FiX, FiX,
} from "react-icons/fi"; } from "react-icons/fi";
@ -50,11 +54,14 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DragHandle } from "@/components/table/DragHandle"; import { DragHandle } from "@/components/table/DragHandle";
import { deletePersona } from "@/app/admin/assistants/lib"; import {
deletePersona,
togglePersonaPublicStatus,
} from "@/app/admin/assistants/lib";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal"; import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { MakePublicAssistantModal } from "@/app/chat/modal/MakePublicAssistantModal";
function DraggableAssistantListItem(props: any) { function DraggableAssistantListItem(props: any) {
const { const {
@ -81,7 +88,7 @@ function DraggableAssistantListItem(props: any) {
<DragHandle /> <DragHandle />
</div> </div>
<div className="flex-grow"> <div className="flex-grow">
<AssistantListItem del {...props} /> <AssistantListItem {...props} />
</div> </div>
</div> </div>
); );
@ -95,6 +102,7 @@ function AssistantListItem({
isVisible, isVisible,
setPopup, setPopup,
deleteAssistant, deleteAssistant,
shareAssistant,
}: { }: {
assistant: Persona; assistant: Persona;
user: User | null; user: User | null;
@ -102,7 +110,7 @@ function AssistantListItem({
allAssistantIds: string[]; allAssistantIds: string[];
isVisible: boolean; isVisible: boolean;
deleteAssistant: Dispatch<SetStateAction<Persona | null>>; deleteAssistant: Dispatch<SetStateAction<Persona | null>>;
shareAssistant: Dispatch<SetStateAction<Persona | null>>;
setPopup: (popupSpec: PopupSpec | null) => void; setPopup: (popupSpec: PopupSpec | null) => void;
}) { }) {
const router = useRouter(); const router = useRouter();
@ -258,6 +266,18 @@ function AssistantListItem({
) : ( ) : (
<></> <></>
), ),
isOwnedByUser ? (
<div
key="delete"
className="flex items-center gap-x-2"
onClick={() => shareAssistant(assistant)}
>
{assistant.is_public ? <FiMinus /> : <FiPlus />} Make{" "}
{assistant.is_public ? "Private" : "Public"}
</div>
) : (
<></>
),
]} ]}
</DefaultPopover> </DefaultPopover>
</div> </div>
@ -290,6 +310,9 @@ export function AssistantsList({
assistant.id.toString() assistant.id.toString()
); );
const [deletingPersona, setDeletingPersona] = useState<Persona | null>(null); const [deletingPersona, setDeletingPersona] = useState<Persona | null>(null);
const [makePublicPersona, setMakePublicPersona] = useState<Persona | null>(
null
);
const { popup, setPopup } = usePopup(); const { popup, setPopup } = usePopup();
const router = useRouter(); const router = useRouter();
@ -307,7 +330,7 @@ export function AssistantsList({
async function handleDragEnd(event: DragEndEvent) { async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event; const { active, over } = event;
filteredAssistants;
if (over && active.id !== over.id) { if (over && active.id !== over.id) {
setFilteredAssistants((assistants) => { setFilteredAssistants((assistants) => {
const oldIndex = assistants.findIndex( const oldIndex = assistants.findIndex(
@ -351,6 +374,20 @@ export function AssistantsList({
/> />
)} )}
{makePublicPersona && (
<MakePublicAssistantModal
isPublic={makePublicPersona.is_public}
onClose={() => setMakePublicPersona(null)}
onShare={async (newPublicStatus: boolean) => {
await togglePersonaPublicStatus(
makePublicPersona.id,
newPublicStatus
);
router.refresh();
}}
/>
)}
<div className="mx-auto mobile:w-[90%] desktop:w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar"> <div className="mx-auto mobile:w-[90%] desktop:w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar">
<AssistantsPageTitle>My Assistants</AssistantsPageTitle> <AssistantsPageTitle>My Assistants</AssistantsPageTitle>
@ -403,6 +440,7 @@ export function AssistantsList({
{filteredAssistants.map((assistant, index) => ( {filteredAssistants.map((assistant, index) => (
<DraggableAssistantListItem <DraggableAssistantListItem
deleteAssistant={setDeletingPersona} deleteAssistant={setDeletingPersona}
shareAssistant={setMakePublicPersona}
key={assistant.id} key={assistant.id}
assistant={assistant} assistant={assistant}
user={user} user={user}
@ -431,6 +469,7 @@ export function AssistantsList({
{ownedButHiddenAssistants.map((assistant, index) => ( {ownedButHiddenAssistants.map((assistant, index) => (
<AssistantListItem <AssistantListItem
deleteAssistant={setDeletingPersona} deleteAssistant={setDeletingPersona}
shareAssistant={setMakePublicPersona}
key={assistant.id} key={assistant.id}
assistant={assistant} assistant={assistant}
user={user} user={user}

View File

@ -0,0 +1,72 @@
import { ModalWrapper } from "@/components/modals/ModalWrapper";
import { Button, Divider, Text } from "@tremor/react";
export function MakePublicAssistantModal({
isPublic,
onShare,
onClose,
}: {
isPublic: boolean;
onShare: (shared: boolean) => void;
onClose: () => void;
}) {
return (
<ModalWrapper onClose={onClose} modalClassName="max-w-3xl">
<div className="space-y-6">
<h2 className="text-2xl font-bold text-emphasis">
{isPublic ? "Public Assistant" : "Make Assistant Public"}
</h2>
<Text>
This assistant is currently{" "}
<span className="font-semibold">
{isPublic ? "public" : "private"}
</span>
.
{isPublic
? " Anyone can currently access this assistant."
: " Only you can access this assistant."}
</Text>
<Divider />
{isPublic ? (
<div className="space-y-4">
<Text>
To restrict access to this assistant, you can make it private
again.
</Text>
<Button
onClick={async () => {
await onShare?.(false);
onClose();
}}
size="sm"
color="red"
>
Make Assistant Private
</Button>
</div>
) : (
<div className="space-y-4">
<Text>
Making this assistant public will allow anyone with the link to
view and use it. Ensure that all content and capabilities of the
assistant are safe to share.
</Text>
<Button
onClick={async () => {
await onShare?.(true);
onClose();
}}
size="sm"
color="green"
>
Make Assistant Public
</Button>
</div>
)}
</div>
</ModalWrapper>
);
}