Skip to content

Reduce amount of stuff we fetch on /persona #4988

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 46 additions & 15 deletions backend/onyx/db/persona.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from collections.abc import Sequence
from datetime import datetime
from enum import Enum
from uuid import UUID

from fastapi import HTTPException
Expand All @@ -11,7 +12,6 @@
from sqlalchemy import update
from sqlalchemy.orm import aliased
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session

from onyx.auth.schemas import UserRole
Expand All @@ -22,6 +22,7 @@
from onyx.configs.constants import NotificationType
from onyx.context.search.enums import RecencyBiasSetting
from onyx.db.constants import SLACK_BOT_PERSONA_PREFIX
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import DocumentSet
from onyx.db.models import Persona
from onyx.db.models import Persona__User
Expand All @@ -45,6 +46,12 @@
logger = setup_logger()


class PersonaLoadType(Enum):
NONE = "none"
MINIMAL = "minimal"
FULL = "full"


def _add_user_filters(
stmt: Select, user: User | None, get_editable: bool = True
) -> Select:
Expand Down Expand Up @@ -322,16 +329,15 @@ def update_persona_public_status(


def get_personas_for_user(
# defines how much of the persona to pre-load
load_type: PersonaLoadType,
# if user is `None` assume the user is an admin or auth is disabled
user: User | None,
db_session: Session,
get_editable: bool = True,
include_default: bool = True,
include_slack_bot_personas: bool = False,
include_deleted: bool = False,
joinedload_all: bool = False,
# a bit jank
include_prompt: bool = True,
) -> Sequence[Persona]:
stmt = select(Persona)
stmt = _add_user_filters(stmt, user, get_editable)
Expand All @@ -343,20 +349,45 @@ def get_personas_for_user(
if not include_deleted:
stmt = stmt.where(Persona.deleted.is_(False))

if joinedload_all:
if load_type == PersonaLoadType.MINIMAL:
# For ChatPage, only load essential relationships
stmt = stmt.options(
# Used for retrieval capability checking
joinedload(Persona.tools),
# Used for filtering
joinedload(Persona.labels),
# only show document sets in the UI that the assistant has access to
joinedload(Persona.document_sets),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably should include these from below since they get used in the minimalsnapshot:

            .joinedload(DocumentSet.connector_credential_pairs)
            .joinedload(ConnectorCredentialPair.connector),
            joinedload(Persona.document_sets)
            .joinedload(DocumentSet.connector_credential_pairs)
            .joinedload(ConnectorCredentialPair.credential),

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also these:

            joinedload(Persona.document_sets).joinedload(DocumentSet.users),
            joinedload(Persona.document_sets).joinedload(DocumentSet.groups),

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also on user

joinedload(Persona.document_sets)
.joinedload(DocumentSet.connector_credential_pairs)
.joinedload(ConnectorCredentialPair.connector),
joinedload(Persona.document_sets)
.joinedload(DocumentSet.connector_credential_pairs)
.joinedload(ConnectorCredentialPair.credential),
# user
joinedload(Persona.user),
)
elif load_type == PersonaLoadType.FULL:
stmt = stmt.options(
selectinload(Persona.tools),
selectinload(Persona.document_sets),
selectinload(Persona.groups),
selectinload(Persona.users),
selectinload(Persona.labels),
selectinload(Persona.user_files),
selectinload(Persona.user_folders),
joinedload(Persona.user),
joinedload(Persona.tools),
joinedload(Persona.document_sets)
.joinedload(DocumentSet.connector_credential_pairs)
.joinedload(ConnectorCredentialPair.connector),
joinedload(Persona.document_sets)
.joinedload(DocumentSet.connector_credential_pairs)
.joinedload(ConnectorCredentialPair.credential),
joinedload(Persona.document_sets).joinedload(DocumentSet.users),
joinedload(Persona.document_sets).joinedload(DocumentSet.groups),
joinedload(Persona.groups),
joinedload(Persona.users),
joinedload(Persona.labels),
joinedload(Persona.user_files),
joinedload(Persona.user_folders),
joinedload(Persona.prompts),
)
if include_prompt:
stmt = stmt.options(selectinload(Persona.prompts))

results = db_session.execute(stmt).scalars().all()
results = db_session.execute(stmt).unique().scalars().all()
return results


Expand Down
12 changes: 7 additions & 5 deletions backend/onyx/server/features/persona/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from onyx.db.persona import get_personas_for_user
from onyx.db.persona import mark_persona_as_deleted
from onyx.db.persona import mark_persona_as_not_deleted
from onyx.db.persona import PersonaLoadType
from onyx.db.persona import update_all_personas_display_priority
from onyx.db.persona import update_persona_is_default
from onyx.db.persona import update_persona_label
Expand All @@ -45,6 +46,7 @@
from onyx.server.features.persona.models import FullPersonaSnapshot
from onyx.server.features.persona.models import GenerateStarterMessageRequest
from onyx.server.features.persona.models import ImageGenerationToolStatus
from onyx.server.features.persona.models import MinimalPersonaSnapshot
from onyx.server.features.persona.models import PersonaLabelCreate
from onyx.server.features.persona.models import PersonaLabelResponse
from onyx.server.features.persona.models import PersonaSharedNotificationData
Expand Down Expand Up @@ -154,7 +156,7 @@ def list_personas_admin(
user=user,
get_editable=get_editable,
include_deleted=include_deleted,
joinedload_all=True,
load_type=PersonaLoadType.FULL,
)
]

Expand Down Expand Up @@ -393,14 +395,13 @@ def list_personas(
db_session: Session = Depends(get_session),
include_deleted: bool = False,
persona_ids: list[int] = Query(None),
) -> list[PersonaSnapshot]:
) -> list[MinimalPersonaSnapshot]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Include return type details in docstring. This is a breaking API change since the return type changed from PersonaSnapshot to MinimalPersonaSnapshot.

personas = get_personas_for_user(
load_type=PersonaLoadType.MINIMAL,
user=user,
include_deleted=include_deleted,
db_session=db_session,
get_editable=False,
joinedload_all=True,
include_prompt=False,
)

if persona_ids:
Expand All @@ -416,7 +417,8 @@ def list_personas(
)
]

return [PersonaSnapshot.from_model(p) for p in personas]
result = [MinimalPersonaSnapshot.from_model(p) for p in personas]
return result


@basic_router.get("/{persona_id}")
Expand Down
58 changes: 58 additions & 0 deletions backend/onyx/server/features/persona/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,64 @@
logger = setup_logger()


class MinimalPersonaSnapshot(BaseModel):
"""Minimal persona model optimized for ChatPage.tsx - only includes fields actually used"""

# Core fields used by ChatPage
id: int
name: str
description: str
tools: list[ToolSnapshot]
starter_messages: list[StarterMessage] | None
document_sets: list[DocumentSet]
llm_model_version_override: str | None
llm_model_provider_override: str | None

uploaded_image_id: str | None
icon_shape: int | None
icon_color: str | None

is_public: bool
is_visible: bool
display_priority: int | None
is_default_persona: bool
builtin_persona: bool

labels: list["PersonaLabelSnapshot"]
owner: MinimalUserSnapshot | None

@classmethod
def from_model(cls, persona: Persona) -> "MinimalPersonaSnapshot":
return MinimalPersonaSnapshot(
# Core fields actually used by ChatPage
id=persona.id,
name=persona.name,
description=persona.description,
tools=[ToolSnapshot.from_model(tool) for tool in persona.tools],
starter_messages=persona.starter_messages,
document_sets=[
DocumentSet.from_model(document_set)
for document_set in persona.document_sets
],
llm_model_version_override=persona.llm_model_version_override,
llm_model_provider_override=persona.llm_model_provider_override,
uploaded_image_id=persona.uploaded_image_id,
icon_shape=persona.icon_shape,
icon_color=persona.icon_color,
is_public=persona.is_public,
is_visible=persona.is_visible,
display_priority=persona.display_priority,
is_default_persona=persona.is_default_persona,
builtin_persona=persona.builtin_persona,
labels=[PersonaLabelSnapshot.from_model(label) for label in persona.labels],
owner=(
MinimalUserSnapshot(id=persona.user.id, email=persona.user.email)
if persona.user
else None
),
Comment on lines +71 to +75
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: persona.user is accessed before the null check. Move the persona.user.id and email access inside the parentheses to prevent potential NullPointerException.

Suggested change
owner=(
MinimalUserSnapshot(id=persona.user.id, email=persona.user.email)
if persona.user
else None
),
owner=(
MinimalUserSnapshot(id=user.id, email=user.email)
if (user := persona.user)
else None
),

)


class PromptSnapshot(BaseModel):
id: int
name: str
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from onyx.db.persona import get_persona_by_id
from onyx.db.persona import get_personas_for_user
from onyx.db.persona import mark_persona_as_deleted
from onyx.db.persona import PersonaLoadType
from onyx.db.persona import upsert_persona
from onyx.db.prompts import upsert_prompt
from onyx.db.tools import get_tool_by_name
Expand Down Expand Up @@ -244,10 +245,10 @@ def list_assistants(
) -> ListAssistantsResponse:
personas = list(
get_personas_for_user(
load_type=PersonaLoadType.FULL,
user=user,
db_session=db_session,
get_editable=False,
joinedload_all=True,
)
)

Expand Down
8 changes: 3 additions & 5 deletions web/src/app/admin/assistants/AssistantEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export function AssistantEditor({
existingPersona?.llm_model_version_override ?? null,
starter_messages: existingPersona?.starter_messages?.length
? existingPersona.starter_messages
: [{ message: "" }],
: [{ message: "", name: "" }],
enabled_tools_map: enabledToolsMap,
icon_color: existingPersona?.icon_color ?? defautIconColor,
icon_shape: existingPersona?.icon_shape ?? defaultIconShape,
Expand Down Expand Up @@ -526,10 +526,8 @@ export function AssistantEditor({
// to tell the backend to not fetch any documents
const numChunks = searchToolEnabled ? values.num_chunks || 25 : 0;
const starterMessages = values.starter_messages
.filter(
(message: { message: string }) => message.message.trim() !== ""
)
.map((message: { message: string; name?: string }) => ({
.filter((message: StarterMessage) => message.message.trim() !== "")
.map((message: StarterMessage) => ({
message: message.message,
name: message.message,
}));
Expand Down
37 changes: 19 additions & 18 deletions web/src/app/admin/assistants/PersonaTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
import { FiEdit2 } from "react-icons/fi";
import { TrashIcon } from "@/components/icons/icons";
import { useUser } from "@/components/user/UserProvider";
import { useAssistants } from "@/components/context/AssistantsContext";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";

function PersonaTypeDisplay({ persona }: { persona: Persona }) {
Expand All @@ -40,16 +39,18 @@ function PersonaTypeDisplay({ persona }: { persona: Persona }) {
return <Text>Personal {persona.owner && <>({persona.owner.email})</>}</Text>;
}

export function PersonasTable() {
export function PersonasTable({
personas,
refreshPersonas,
}: {
personas: Persona[];
refreshPersonas: () => void;
}) {
const router = useRouter();
const { popup, setPopup } = usePopup();
const { refreshUser, isAdmin } = useUser();
const {
allAssistants: assistants,
refreshAssistants,
editablePersonas,
} = useAssistants();

const editablePersonas = personas.filter((p) => !p.builtin_persona);
const editablePersonaIds = useMemo(() => {
return new Set(editablePersonas.map((p) => p.id.toString()));
}, [editablePersonas]);
Expand All @@ -63,18 +64,18 @@ export function PersonasTable() {

useEffect(() => {
const editable = editablePersonas.sort(personaComparator);
const nonEditable = assistants
const nonEditable = personas
.filter((p) => !editablePersonaIds.has(p.id.toString()))
.sort(personaComparator);
setFinalPersonas([...editable, ...nonEditable]);
}, [editablePersonas, assistants, editablePersonaIds]);
}, [editablePersonas, personas, editablePersonaIds]);

const updatePersonaOrder = async (orderedPersonaIds: UniqueIdentifier[]) => {
const reorderedAssistants = orderedPersonaIds.map(
(id) => assistants.find((assistant) => assistant.id.toString() === id)!
const reorderedPersonas = orderedPersonaIds.map(
(id) => personas.find((persona) => persona.id.toString() === id)!
);
Comment on lines +74 to 76
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Add null check here. The ! operator assumes the find will always succeed, but this could throw if the ID doesn't exist

Suggested change
const reorderedPersonas = orderedPersonaIds.map(
(id) => personas.find((persona) => persona.id.toString() === id)!
);
const reorderedPersonas = orderedPersonaIds.map((id) => {
const persona = personas.find((p) => p.id.toString() === id);
if (!persona) {
throw new Error(`Persona with id ${id} not found`);
}
return persona;
});


setFinalPersonas(reorderedAssistants);
setFinalPersonas(reorderedPersonas);

const displayPriorityMap = new Map<UniqueIdentifier, number>();
orderedPersonaIds.forEach((personaId, ind) => {
Expand All @@ -96,12 +97,12 @@ export function PersonasTable() {
type: "error",
message: `Failed to update persona order - ${await response.text()}`,
});
setFinalPersonas(assistants);
await refreshAssistants();
setFinalPersonas(personas);
await refreshPersonas();
return;
}

await refreshAssistants();
await refreshPersonas();
await refreshUser();
};

Expand All @@ -119,7 +120,7 @@ export function PersonasTable() {
if (personaToDelete) {
const response = await deletePersona(personaToDelete.id);
if (response.ok) {
await refreshAssistants();
refreshPersonas();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Missing await keyword - refreshPersonas should be awaited before closing modal to ensure UI consistency

Suggested change
refreshPersonas();
await refreshPersonas();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potential greptile w?

closeDeleteModal();
} else {
setPopup({
Expand Down Expand Up @@ -147,7 +148,7 @@ export function PersonasTable() {
personaToToggleDefault.is_default_persona
);
if (response.ok) {
await refreshAssistants();
refreshPersonas();
closeDefaultModal();
} else {
setPopup({
Expand Down Expand Up @@ -267,7 +268,7 @@ export function PersonasTable() {
persona.is_visible
);
if (response.ok) {
await refreshAssistants();
refreshPersonas();
} else {
setPopup({
type: "error",
Expand Down
Loading
Loading