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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 not specified, all assistants are shown
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(

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(
user_id: UUID | None,
db_session: Session,
@ -551,6 +567,7 @@ def update_persona_visibility(
persona = fetch_persona_by_id(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
persona.is_visible = is_visible
db_session.commit()

View File

@ -3,6 +3,7 @@ from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi import UploadFile
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_not_deleted
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_visibility
from danswer.file_store.file_store import get_default_file_store
@ -43,6 +45,10 @@ class IsVisibleRequest(BaseModel):
is_visible: bool
class IsPublicRequest(BaseModel):
is_public: bool
@admin_router.patch("/{persona_id}/visible")
def patch_persona_visibility(
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")
def patch_persona_display_priority(
display_priority_request: DisplayPriorityRequest,

View File

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

View File

@ -27,4 +27,4 @@ spec:
key: redis_password
envFrom:
- configMapRef:
name: env-configmap
name: env-configmap

View File

@ -8,7 +8,11 @@ import { usePopup } from "@/components/admin/connectors/Popup";
import { useState, useMemo, useEffect } from "react";
import { UniqueIdentifier } from "@dnd-kit/core";
import { DraggableTable } from "@/components/table/DraggableTable";
import { deletePersona, personaComparator } from "./lib";
import {
deletePersona,
personaComparator,
togglePersonaVisibility,
} from "./lib";
import { FiEdit2 } from "react-icons/fi";
import { TrashIcon } from "@/components/icons/icons";
import { getCurrentUser } from "@/lib/user";
@ -31,22 +35,6 @@ function PersonaTypeDisplay({ persona }: { persona: Persona }) {
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({
allPersonas,
editablePersonas,

View File

@ -320,6 +320,38 @@ export function personaComparator(a: Persona, b: Persona) {
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) {
for (const tool of persona.tools) {
if (tool.name === "ImageGenerationTool") {

View File

@ -1,14 +1,7 @@
import { User } from "@/lib/types";
import { Persona } from "../admin/assistants/interfaces";
import { checkUserOwnsAssistant } from "@/lib/assistants/checkOwnership";
import {
FiImage,
FiLock,
FiMoreHorizontal,
FiSearch,
FiUnlock,
} from "react-icons/fi";
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
import { FiLock, FiUnlock } from "react-icons/fi";
export function AssistantSharedStatusDisplay({
assistant,
@ -56,20 +49,6 @@ export function AssistantSharedStatusDisplay({
)}
</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>
);
}

View File

@ -6,11 +6,15 @@ import { Persona } from "@/app/admin/assistants/interfaces";
import { Divider, Text } from "@tremor/react";
import {
FiEdit2,
FiFigma,
FiMenu,
FiMinus,
FiMoreHorizontal,
FiPlus,
FiSearch,
FiShare,
FiShare2,
FiToggleLeft,
FiTrash,
FiX,
} from "react-icons/fi";
@ -50,11 +54,14 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
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 { MakePublicAssistantModal } from "@/app/chat/modal/MakePublicAssistantModal";
function DraggableAssistantListItem(props: any) {
const {
@ -81,7 +88,7 @@ function DraggableAssistantListItem(props: any) {
<DragHandle />
</div>
<div className="flex-grow">
<AssistantListItem del {...props} />
<AssistantListItem {...props} />
</div>
</div>
);
@ -95,6 +102,7 @@ function AssistantListItem({
isVisible,
setPopup,
deleteAssistant,
shareAssistant,
}: {
assistant: Persona;
user: User | null;
@ -102,7 +110,7 @@ function AssistantListItem({
allAssistantIds: string[];
isVisible: boolean;
deleteAssistant: Dispatch<SetStateAction<Persona | null>>;
shareAssistant: Dispatch<SetStateAction<Persona | null>>;
setPopup: (popupSpec: PopupSpec | null) => void;
}) {
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>
</div>
@ -290,6 +310,9 @@ export function AssistantsList({
assistant.id.toString()
);
const [deletingPersona, setDeletingPersona] = useState<Persona | null>(null);
const [makePublicPersona, setMakePublicPersona] = useState<Persona | null>(
null
);
const { popup, setPopup } = usePopup();
const router = useRouter();
@ -307,7 +330,7 @@ export function AssistantsList({
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
filteredAssistants;
if (over && active.id !== over.id) {
setFilteredAssistants((assistants) => {
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">
<AssistantsPageTitle>My Assistants</AssistantsPageTitle>
@ -403,6 +440,7 @@ export function AssistantsList({
{filteredAssistants.map((assistant, index) => (
<DraggableAssistantListItem
deleteAssistant={setDeletingPersona}
shareAssistant={setMakePublicPersona}
key={assistant.id}
assistant={assistant}
user={user}
@ -431,6 +469,7 @@ export function AssistantsList({
{ownedButHiddenAssistants.map((assistant, index) => (
<AssistantListItem
deleteAssistant={setDeletingPersona}
shareAssistant={setMakePublicPersona}
key={assistant.id}
assistant={assistant}
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>
);
}