From 25389c5120612933dcfdad2d8a02f1199bbbe2f0 Mon Sep 17 00:00:00 2001 From: rkuo-danswer Date: Wed, 26 Feb 2025 13:32:01 -0800 Subject: [PATCH 01/10] first cut at anonymizing query history (#4123) Co-authored-by: Richard Kuo --- backend/ee/onyx/server/query_history/api.py | 53 +++++++++++++++++++-- backend/onyx/configs/app_configs.py | 4 ++ backend/onyx/configs/constants.py | 6 +++ backend/onyx/server/settings/models.py | 2 + backend/onyx/server/settings/store.py | 2 + web/src/app/admin/settings/interfaces.ts | 7 +++ web/src/components/admin/ClientLayout.tsx | 31 +++++++----- web/src/components/settings/lib.ts | 2 + 8 files changed, 91 insertions(+), 16 deletions(-) diff --git a/backend/ee/onyx/server/query_history/api.py b/backend/ee/onyx/server/query_history/api.py index 1468f07c0..b7dfbb6e0 100644 --- a/backend/ee/onyx/server/query_history/api.py +++ b/backend/ee/onyx/server/query_history/api.py @@ -2,6 +2,7 @@ import csv import io from datetime import datetime from datetime import timezone +from http import HTTPStatus from uuid import UUID from fastapi import APIRouter @@ -21,8 +22,10 @@ from ee.onyx.server.query_history.models import QuestionAnswerPairSnapshot from onyx.auth.users import current_admin_user from onyx.auth.users import get_display_email from onyx.chat.chat_utils import create_chat_chain +from onyx.configs.app_configs import ONYX_QUERY_HISTORY_TYPE from onyx.configs.constants import MessageType from onyx.configs.constants import QAFeedbackType +from onyx.configs.constants import QueryHistoryType from onyx.configs.constants import SessionType from onyx.db.chat import get_chat_session_by_id from onyx.db.chat import get_chat_sessions_by_user @@ -35,6 +38,8 @@ from onyx.server.query_and_chat.models import ChatSessionsResponse router = APIRouter() +ONYX_ANONYMIZED_EMAIL = "anonymous@anonymous.invalid" + def fetch_and_process_chat_session_history( db_session: Session, @@ -107,6 +112,17 @@ def get_user_chat_sessions( _: User | None = Depends(current_admin_user), db_session: Session = Depends(get_session), ) -> ChatSessionsResponse: + # we specifically don't allow this endpoint if "anonymized" since + # this is a direct query on the user id + if ONYX_QUERY_HISTORY_TYPE in [ + QueryHistoryType.DISABLED, + QueryHistoryType.ANONYMIZED, + ]: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Per user query history has been disabled by the administrator.", + ) + try: chat_sessions = get_chat_sessions_by_user( user_id=user_id, deleted=False, db_session=db_session, limit=0 @@ -141,6 +157,12 @@ def get_chat_session_history( _: User | None = Depends(current_admin_user), db_session: Session = Depends(get_session), ) -> PaginatedReturn[ChatSessionMinimal]: + if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.DISABLED: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Query history has been disabled by the administrator.", + ) + page_of_chat_sessions = get_page_of_chat_sessions( page_num=page_num, page_size=page_size, @@ -157,11 +179,16 @@ def get_chat_session_history( feedback_filter=feedback_type, ) + minimal_chat_sessions: list[ChatSessionMinimal] = [] + + for chat_session in page_of_chat_sessions: + minimal_chat_session = ChatSessionMinimal.from_chat_session(chat_session) + if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.ANONYMIZED: + minimal_chat_session.user_email = ONYX_ANONYMIZED_EMAIL + minimal_chat_sessions.append(minimal_chat_session) + return PaginatedReturn( - items=[ - ChatSessionMinimal.from_chat_session(chat_session) - for chat_session in page_of_chat_sessions - ], + items=minimal_chat_sessions, total_items=total_filtered_chat_sessions_count, ) @@ -172,6 +199,12 @@ def get_chat_session_admin( _: User | None = Depends(current_admin_user), db_session: Session = Depends(get_session), ) -> ChatSessionSnapshot: + if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.DISABLED: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Query history has been disabled by the administrator.", + ) + try: chat_session = get_chat_session_by_id( chat_session_id=chat_session_id, @@ -193,6 +226,9 @@ def get_chat_session_admin( f"Could not create snapshot for chat session with id '{chat_session_id}'", ) + if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.ANONYMIZED: + snapshot.user_email = ONYX_ANONYMIZED_EMAIL + return snapshot @@ -203,6 +239,12 @@ def get_query_history_as_csv( end: datetime | None = None, db_session: Session = Depends(get_session), ) -> StreamingResponse: + if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.DISABLED: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Query history has been disabled by the administrator.", + ) + complete_chat_session_history = fetch_and_process_chat_session_history( db_session=db_session, start=start or datetime.fromtimestamp(0, tz=timezone.utc), @@ -213,6 +255,9 @@ def get_query_history_as_csv( question_answer_pairs: list[QuestionAnswerPairSnapshot] = [] for chat_session_snapshot in complete_chat_session_history: + if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.ANONYMIZED: + chat_session_snapshot.user_email = ONYX_ANONYMIZED_EMAIL + question_answer_pairs.extend( QuestionAnswerPairSnapshot.from_chat_session_snapshot(chat_session_snapshot) ) diff --git a/backend/onyx/configs/app_configs.py b/backend/onyx/configs/app_configs.py index 1a4d11c01..3cd33fe42 100644 --- a/backend/onyx/configs/app_configs.py +++ b/backend/onyx/configs/app_configs.py @@ -6,6 +6,7 @@ from typing import cast from onyx.auth.schemas import AuthBackend from onyx.configs.constants import AuthType from onyx.configs.constants import DocumentIndexType +from onyx.configs.constants import QueryHistoryType from onyx.file_processing.enums import HtmlBasedConnectorTransformLinksStrategy ##### @@ -29,6 +30,9 @@ GENERATIVE_MODEL_ACCESS_CHECK_FREQ = int( ) # 1 day DISABLE_GENERATIVE_AI = os.environ.get("DISABLE_GENERATIVE_AI", "").lower() == "true" +ONYX_QUERY_HISTORY_TYPE = QueryHistoryType( + (os.environ.get("ONYX_QUERY_HISTORY_TYPE") or QueryHistoryType.NORMAL.value).lower() +) ##### # Web Configs diff --git a/backend/onyx/configs/constants.py b/backend/onyx/configs/constants.py index 94ca026b1..c47e4d6e5 100644 --- a/backend/onyx/configs/constants.py +++ b/backend/onyx/configs/constants.py @@ -213,6 +213,12 @@ class AuthType(str, Enum): CLOUD = "cloud" +class QueryHistoryType(str, Enum): + DISABLED = "disabled" + ANONYMIZED = "anonymized" + NORMAL = "normal" + + # Special characters for password validation PASSWORD_SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;:,.<>?" diff --git a/backend/onyx/server/settings/models.py b/backend/onyx/server/settings/models.py index 58dd0a51f..7e1e5ef74 100644 --- a/backend/onyx/server/settings/models.py +++ b/backend/onyx/server/settings/models.py @@ -4,6 +4,7 @@ from enum import Enum from pydantic import BaseModel from onyx.configs.constants import NotificationType +from onyx.configs.constants import QueryHistoryType from onyx.db.models import Notification as NotificationDBModel from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA @@ -50,6 +51,7 @@ class Settings(BaseModel): temperature_override_enabled: bool | None = False auto_scroll: bool | None = False + query_history_type: QueryHistoryType | None = None class UserSettings(Settings): diff --git a/backend/onyx/server/settings/store.py b/backend/onyx/server/settings/store.py index 3602fd41e..d35b306f5 100644 --- a/backend/onyx/server/settings/store.py +++ b/backend/onyx/server/settings/store.py @@ -1,3 +1,4 @@ +from onyx.configs.app_configs import ONYX_QUERY_HISTORY_TYPE from onyx.configs.constants import KV_SETTINGS_KEY from onyx.configs.constants import OnyxRedisLocks from onyx.key_value_store.factory import get_kv_store @@ -45,6 +46,7 @@ def load_settings() -> Settings: anonymous_user_enabled = False settings.anonymous_user_enabled = anonymous_user_enabled + settings.query_history_type = ONYX_QUERY_HISTORY_TYPE return settings diff --git a/web/src/app/admin/settings/interfaces.ts b/web/src/app/admin/settings/interfaces.ts index c56875c1d..c4320d605 100644 --- a/web/src/app/admin/settings/interfaces.ts +++ b/web/src/app/admin/settings/interfaces.ts @@ -4,6 +4,12 @@ export enum ApplicationStatus { ACTIVE = "active", } +export enum QueryHistoryType { + DISABLED = "disabled", + ANONYMIZED = "anonymized", + NORMAL = "normal", +} + export interface Settings { anonymous_user_enabled: boolean; maximum_chat_retention_days: number | null; @@ -14,6 +20,7 @@ export interface Settings { application_status: ApplicationStatus; auto_scroll: boolean; temperature_override_enabled: boolean; + query_history_type: QueryHistoryType; } export enum NotificationType { diff --git a/web/src/components/admin/ClientLayout.tsx b/web/src/components/admin/ClientLayout.tsx index 9ccec914a..5a4a1259f 100644 --- a/web/src/components/admin/ClientLayout.tsx +++ b/web/src/components/admin/ClientLayout.tsx @@ -359,18 +359,25 @@ export function ClientLayout({ ), link: "/admin/performance/usage", }, - { - name: ( -
- -
Query History
-
- ), - link: "/admin/performance/query-history", - }, + ...(settings?.settings.query_history_type !== + "disabled" + ? [ + { + name: ( +
+ +
+ Query History +
+
+ ), + link: "/admin/performance/query-history", + }, + ] + : []), { name: (
diff --git a/web/src/components/settings/lib.ts b/web/src/components/settings/lib.ts index 7288a6490..23394b7fc 100644 --- a/web/src/components/settings/lib.ts +++ b/web/src/components/settings/lib.ts @@ -3,6 +3,7 @@ import { EnterpriseSettings, ApplicationStatus, Settings, + QueryHistoryType, } from "@/app/admin/settings/interfaces"; import { CUSTOM_ANALYTICS_ENABLED, @@ -53,6 +54,7 @@ export async function fetchSettingsSS(): Promise { anonymous_user_enabled: false, pro_search_enabled: true, temperature_override_enabled: true, + query_history_type: QueryHistoryType.NORMAL, }; } else { throw new Error( From f2d74ce5400a7b4131b607085fba845a76b59bf8 Mon Sep 17 00:00:00 2001 From: pablonyx Date: Wed, 26 Feb 2025 17:24:23 -0800 Subject: [PATCH 02/10] Address Auth Edge Case (#4138) --- backend/onyx/auth/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/onyx/auth/users.py b/backend/onyx/auth/users.py index f28825f1f..ec163f167 100644 --- a/backend/onyx/auth/users.py +++ b/backend/onyx/auth/users.py @@ -420,7 +420,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): except exceptions.UserNotExists: try: # Attempt to get user by email - user = await self.get_by_email(account_email) + user = cast(User, await self.user_db.get_by_email(account_email)) if not associate_by_email: raise exceptions.UserAlreadyExists() From 11e7e1c4d67f0aa72656170d7a9dc6a46bfe4185 Mon Sep 17 00:00:00 2001 From: rkuo-danswer Date: Wed, 26 Feb 2025 17:26:48 -0800 Subject: [PATCH 03/10] log processed tenant count (#4139) Co-authored-by: Richard Kuo (Danswer) --- backend/onyx/background/celery/tasks/shared/tasks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/onyx/background/celery/tasks/shared/tasks.py b/backend/onyx/background/celery/tasks/shared/tasks.py index 4b5a96ca2..36cb88c3c 100644 --- a/backend/onyx/background/celery/tasks/shared/tasks.py +++ b/backend/onyx/background/celery/tasks/shared/tasks.py @@ -298,6 +298,7 @@ def cloud_beat_task_generator( last_lock_time = time.monotonic() tenant_ids: list[str] = [] + num_processed_tenants = 0 try: tenant_ids = get_all_tenant_ids() @@ -325,6 +326,8 @@ def cloud_beat_task_generator( expires=expires, ignore_result=True, ) + + num_processed_tenants += 1 except SoftTimeLimitExceeded: task_logger.info( "Soft time limit exceeded, task is being terminated gracefully." @@ -344,6 +347,7 @@ def cloud_beat_task_generator( task_logger.info( f"cloud_beat_task_generator finished: " f"task={task_name} " + f"num_processed_tenants={num_processed_tenants} " f"num_tenants={len(tenant_ids)} " f"elapsed={time_elapsed:.2f}" ) From 4dc88ca0379cbf16b4a1d80c0eca5b2890cd8a37 Mon Sep 17 00:00:00 2001 From: pablonyx Date: Wed, 26 Feb 2025 17:32:26 -0800 Subject: [PATCH 04/10] debug playwright failure case --- web/tests/e2e/admin_pages.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/tests/e2e/admin_pages.spec.ts b/web/tests/e2e/admin_pages.spec.ts index 44ee6be1d..f2e973384 100644 --- a/web/tests/e2e/admin_pages.spec.ts +++ b/web/tests/e2e/admin_pages.spec.ts @@ -24,6 +24,8 @@ async function verifyAdminPageNavigation( console.error( `Failed to find h1 with text "${pageTitle}" for path "${path}"` ); + // NOTE: This is a temporary measure for debugging the issue + console.error(await page.content()); throw error; } From a3e3d83b7e4d2b12031023cfba820c8eca8274c4 Mon Sep 17 00:00:00 2001 From: pablonyx Date: Wed, 26 Feb 2025 17:24:39 -0800 Subject: [PATCH 05/10] Improve viewable assistant logic (#4125) * k * quick fix * k --- backend/onyx/db/persona.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/onyx/db/persona.py b/backend/onyx/db/persona.py index 250b26117..b879e426c 100644 --- a/backend/onyx/db/persona.py +++ b/backend/onyx/db/persona.py @@ -100,9 +100,14 @@ def _add_user_filters( .correlate(Persona) ) else: - where_clause |= Persona.is_public == True # noqa: E712 - where_clause &= Persona.is_visible == True # noqa: E712 + # Group the public persona conditions + public_condition = (Persona.is_public == True) & ( # noqa: E712 + Persona.is_visible == True # noqa: E712 + ) + + where_clause |= public_condition where_clause |= Persona__User.user_id == user.id + where_clause |= Persona.user_id == user.id return stmt.where(where_clause) From abb74f2eaa433ff3267fded868166afd090e1230 Mon Sep 17 00:00:00 2001 From: pablonyx Date: Wed, 26 Feb 2025 18:27:45 -0800 Subject: [PATCH 06/10] Improved chat search (#4137) * functional + fast * k * adapt * k * nit * k * k * fix typing * k --- .../versions/3bd4c84fe72f_improved_index.py | 84 ++++++++++ backend/onyx/db/chat_search.py | 157 +++++++----------- backend/onyx/db/models.py | 1 + .../app/chat/chat_search/ChatSearchGroup.tsx | 4 +- .../app/chat/chat_search/ChatSearchItem.tsx | 11 +- 5 files changed, 151 insertions(+), 106 deletions(-) create mode 100644 backend/alembic/versions/3bd4c84fe72f_improved_index.py diff --git a/backend/alembic/versions/3bd4c84fe72f_improved_index.py b/backend/alembic/versions/3bd4c84fe72f_improved_index.py new file mode 100644 index 000000000..ab9497619 --- /dev/null +++ b/backend/alembic/versions/3bd4c84fe72f_improved_index.py @@ -0,0 +1,84 @@ +"""improved index + +Revision ID: 3bd4c84fe72f +Revises: 8f43500ee275 +Create Date: 2025-02-26 13:07:56.217791 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "3bd4c84fe72f" +down_revision = "8f43500ee275" +branch_labels = None +depends_on = None + + +# NOTE: +# This migration addresses issues with the previous migration (8f43500ee275) which caused +# an outage by creating an index without using CONCURRENTLY. This migration: +# +# 1. Creates more efficient full-text search capabilities using tsvector columns and GIN indexes +# 2. Uses CONCURRENTLY for all index creation to prevent table locking +# 3. Explicitly manages transactions with COMMIT statements to allow CONCURRENTLY to work +# (see: https://www.postgresql.org/docs/9.4/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY) +# (see: https://github.com/sqlalchemy/alembic/issues/277) +# 4. Adds indexes to both chat_message and chat_session tables for comprehensive search + + +def upgrade() -> None: + # Create a GIN index for full-text search on chat_message.message + op.execute( + """ + ALTER TABLE chat_message + ADD COLUMN message_tsv tsvector + GENERATED ALWAYS AS (to_tsvector('english', message)) STORED; + """ + ) + + # Commit the current transaction before creating concurrent indexes + op.execute("COMMIT") + + op.execute( + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chat_message_tsv + ON chat_message + USING GIN (message_tsv) + """ + ) + + # Also add a stored tsvector column for chat_session.description + op.execute( + """ + ALTER TABLE chat_session + ADD COLUMN description_tsv tsvector + GENERATED ALWAYS AS (to_tsvector('english', coalesce(description, ''))) STORED; + """ + ) + + # Commit again before creating the second concurrent index + op.execute("COMMIT") + + op.execute( + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chat_session_desc_tsv + ON chat_session + USING GIN (description_tsv) + """ + ) + + +def downgrade() -> None: + # Drop the indexes first (use CONCURRENTLY for dropping too) + op.execute("COMMIT") + op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_chat_message_tsv;") + + op.execute("COMMIT") + op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_chat_session_desc_tsv;") + + # Then drop the columns + op.execute("ALTER TABLE chat_message DROP COLUMN IF EXISTS message_tsv;") + op.execute("ALTER TABLE chat_session DROP COLUMN IF EXISTS description_tsv;") + + op.execute("DROP INDEX IF EXISTS idx_chat_message_message_lower;") diff --git a/backend/onyx/db/chat_search.py b/backend/onyx/db/chat_search.py index fd9a69b22..8fb511680 100644 --- a/backend/onyx/db/chat_search.py +++ b/backend/onyx/db/chat_search.py @@ -3,14 +3,13 @@ from typing import Optional from typing import Tuple from uuid import UUID +from sqlalchemy import column from sqlalchemy import desc from sqlalchemy import func -from sqlalchemy import literal -from sqlalchemy import Select from sqlalchemy import select -from sqlalchemy import union_all from sqlalchemy.orm import joinedload from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import ColumnClause from onyx.db.models import ChatMessage from onyx.db.models import ChatSession @@ -26,127 +25,87 @@ def search_chat_sessions( include_onyxbot_flows: bool = False, ) -> Tuple[List[ChatSession], bool]: """ - Search for chat sessions based on the provided query. - If no query is provided, returns recent chat sessions. + Fast full-text search on ChatSession + ChatMessage using tsvectors. - Returns a tuple of (chat_sessions, has_more) + If no query is provided, returns the most recent chat sessions. + Otherwise, searches both chat messages and session descriptions. + + Returns a tuple of (sessions, has_more) where has_more indicates if + there are additional results beyond the requested page. """ - offset = (page - 1) * page_size + offset_val = (page - 1) * page_size - # If no search query, we use standard SQLAlchemy pagination + # If no query, just return the most recent sessions if not query or not query.strip(): - stmt = select(ChatSession) - if user_id: + stmt = ( + select(ChatSession) + .order_by(desc(ChatSession.time_created)) + .offset(offset_val) + .limit(page_size + 1) + ) + if user_id is not None: stmt = stmt.where(ChatSession.user_id == user_id) if not include_onyxbot_flows: stmt = stmt.where(ChatSession.onyxbot_flow.is_(False)) if not include_deleted: stmt = stmt.where(ChatSession.deleted.is_(False)) - stmt = stmt.order_by(desc(ChatSession.time_created)) - - # Apply pagination - stmt = stmt.offset(offset).limit(page_size + 1) result = db_session.execute(stmt.options(joinedload(ChatSession.persona))) - chat_sessions = result.scalars().all() + sessions = result.scalars().all() - has_more = len(chat_sessions) > page_size + has_more = len(sessions) > page_size if has_more: - chat_sessions = chat_sessions[:page_size] + sessions = sessions[:page_size] - return list(chat_sessions), has_more + return list(sessions), has_more - words = query.lower().strip().split() + # Otherwise, proceed with full-text search + query = query.strip() - # Message mach subquery - message_matches = [] - for word in words: - word_like = f"%{word}%" - message_match: Select = ( - select(ChatMessage.chat_session_id, literal(1.0).label("search_rank")) - .join(ChatSession, ChatSession.id == ChatMessage.chat_session_id) - .where(func.lower(ChatMessage.message).like(word_like)) - ) - - if user_id: - message_match = message_match.where(ChatSession.user_id == user_id) - - message_matches.append(message_match) - - if message_matches: - message_matches_query = union_all(*message_matches).alias("message_matches") - else: - return [], False - - # Description matches - description_match: Select = select( - ChatSession.id.label("chat_session_id"), literal(0.5).label("search_rank") - ).where(func.lower(ChatSession.description).like(f"%{query.lower()}%")) - - if user_id: - description_match = description_match.where(ChatSession.user_id == user_id) + base_conditions = [] + if user_id is not None: + base_conditions.append(ChatSession.user_id == user_id) if not include_onyxbot_flows: - description_match = description_match.where(ChatSession.onyxbot_flow.is_(False)) + base_conditions.append(ChatSession.onyxbot_flow.is_(False)) if not include_deleted: - description_match = description_match.where(ChatSession.deleted.is_(False)) + base_conditions.append(ChatSession.deleted.is_(False)) - # Combine all match sources - combined_matches = union_all( - message_matches_query.select(), description_match - ).alias("combined_matches") + message_tsv: ColumnClause = column("message_tsv") + description_tsv: ColumnClause = column("description_tsv") - # Use CTE to group and get max rank - session_ranks = ( - select( - combined_matches.c.chat_session_id, - func.max(combined_matches.c.search_rank).label("rank"), - ) - .group_by(combined_matches.c.chat_session_id) - .alias("session_ranks") + ts_query = func.plainto_tsquery("english", query) + + description_session_ids = ( + select(ChatSession.id) + .where(*base_conditions) + .where(description_tsv.op("@@")(ts_query)) ) - # Get ranked sessions with pagination - ranked_query = ( - db_session.query(session_ranks.c.chat_session_id, session_ranks.c.rank) - .order_by(desc(session_ranks.c.rank), session_ranks.c.chat_session_id) - .offset(offset) + message_session_ids = ( + select(ChatMessage.chat_session_id) + .join(ChatSession, ChatMessage.chat_session_id == ChatSession.id) + .where(*base_conditions) + .where(message_tsv.op("@@")(ts_query)) + ) + + combined_ids = description_session_ids.union(message_session_ids).alias( + "combined_ids" + ) + + final_stmt = ( + select(ChatSession) + .join(combined_ids, ChatSession.id == combined_ids.c.id) + .order_by(desc(ChatSession.time_created)) + .distinct() + .offset(offset_val) .limit(page_size + 1) + .options(joinedload(ChatSession.persona)) ) - result = ranked_query.all() + session_objs = db_session.execute(final_stmt).scalars().all() - # Extract session IDs and ranks - session_ids_with_ranks = {row.chat_session_id: row.rank for row in result} - session_ids = list(session_ids_with_ranks.keys()) - - if not session_ids: - return [], False - - # Now, let's query the actual ChatSession objects using the IDs - stmt = select(ChatSession).where(ChatSession.id.in_(session_ids)) - - if user_id: - stmt = stmt.where(ChatSession.user_id == user_id) - if not include_onyxbot_flows: - stmt = stmt.where(ChatSession.onyxbot_flow.is_(False)) - if not include_deleted: - stmt = stmt.where(ChatSession.deleted.is_(False)) - - # Full objects with eager loading - result = db_session.execute(stmt.options(joinedload(ChatSession.persona))) - chat_sessions = result.scalars().all() - - # Sort based on above ranking - chat_sessions = sorted( - chat_sessions, - key=lambda session: ( - -session_ids_with_ranks.get(session.id, 0), # Rank (higher first) - session.time_created.timestamp() * -1, # Then by time (newest first) - ), - ) - - has_more = len(chat_sessions) > page_size + has_more = len(session_objs) > page_size if has_more: - chat_sessions = chat_sessions[:page_size] + session_objs = session_objs[:page_size] - return chat_sessions, has_more + return list(session_objs), has_more diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index 132b2d63f..0001ec318 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -25,6 +25,7 @@ from sqlalchemy import ForeignKey from sqlalchemy import func from sqlalchemy import Index from sqlalchemy import Integer + from sqlalchemy import Sequence from sqlalchemy import String from sqlalchemy import Text diff --git a/web/src/app/chat/chat_search/ChatSearchGroup.tsx b/web/src/app/chat/chat_search/ChatSearchGroup.tsx index 6d3dee74c..93be2fa93 100644 --- a/web/src/app/chat/chat_search/ChatSearchGroup.tsx +++ b/web/src/app/chat/chat_search/ChatSearchGroup.tsx @@ -15,8 +15,8 @@ export function ChatSearchGroup({ }: ChatSearchGroupProps) { return (
-
-
+
+
{title}
diff --git a/web/src/app/chat/chat_search/ChatSearchItem.tsx b/web/src/app/chat/chat_search/ChatSearchItem.tsx index 3b0320181..6b72ea305 100644 --- a/web/src/app/chat/chat_search/ChatSearchItem.tsx +++ b/web/src/app/chat/chat_search/ChatSearchItem.tsx @@ -1,6 +1,7 @@ import React from "react"; import { MessageSquare } from "lucide-react"; import { ChatSessionSummary } from "./interfaces"; +import { truncateString } from "@/lib/utils"; interface ChatSearchItemProps { chat: ChatSessionSummary; @@ -11,12 +12,12 @@ export function ChatSearchItem({ chat, onSelect }: ChatSearchItemProps) { return (
  • onSelect(chat.id)}> -
    -
    +
    +
    -
    -
    - {chat.name || "Untitled Chat"} +
    +
    + {truncateString(chat.name || "Untitled Chat", 90)}
    From 2f64031f5c732edae08a29dbf80fb3f6325d1161 Mon Sep 17 00:00:00 2001 From: pablonyx Date: Wed, 26 Feb 2025 19:40:50 -0800 Subject: [PATCH 07/10] Improved tenant handling for slack bot1 (#4104) --- .../onyx/background/indexing/job_client.py | 2 +- backend/onyx/onyxbot/slack/blocks.py | 7 +- .../onyxbot/slack/handlers/handle_buttons.py | 10 +-- .../onyxbot/slack/handlers/handle_message.py | 10 +-- .../slack/handlers/handle_regular_answer.py | 11 +-- backend/onyx/onyxbot/slack/listener.py | 79 ++++++++----------- backend/onyx/onyxbot/slack/utils.py | 37 +++++++-- 7 files changed, 79 insertions(+), 77 deletions(-) diff --git a/backend/onyx/background/indexing/job_client.py b/backend/onyx/background/indexing/job_client.py index 772fb8d52..db8b5e3b4 100644 --- a/backend/onyx/background/indexing/job_client.py +++ b/backend/onyx/background/indexing/job_client.py @@ -16,7 +16,7 @@ from typing import Optional from onyx.configs.constants import POSTGRES_CELERY_WORKER_INDEXING_CHILD_APP_NAME from onyx.db.engine import SqlEngine -from onyx.utils.logger import setup_logger +from onyx.setup import setup_logger from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA from shared_configs.configs import TENANT_ID_PREFIX from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR diff --git a/backend/onyx/onyxbot/slack/blocks.py b/backend/onyx/onyxbot/slack/blocks.py index 2c2138253..9eae5d017 100644 --- a/backend/onyx/onyxbot/slack/blocks.py +++ b/backend/onyx/onyxbot/slack/blocks.py @@ -23,7 +23,7 @@ from onyx.configs.constants import SearchFeedbackType from onyx.configs.onyxbot_configs import DANSWER_BOT_NUM_DOCS_TO_DISPLAY from onyx.context.search.models import SavedSearchDoc from onyx.db.chat import get_chat_session_by_message_id -from onyx.db.engine import get_session_with_tenant +from onyx.db.engine import get_session_with_current_tenant from onyx.db.models import ChannelConfig from onyx.onyxbot.slack.constants import CONTINUE_IN_WEB_UI_ACTION_ID from onyx.onyxbot.slack.constants import DISLIKE_BLOCK_ACTION_ID @@ -410,12 +410,11 @@ def _build_qa_response_blocks( def _build_continue_in_web_ui_block( - tenant_id: str, message_id: int | None, ) -> Block: if message_id is None: raise ValueError("No message id provided to build continue in web ui block") - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: chat_session = get_chat_session_by_message_id( db_session=db_session, message_id=message_id, @@ -482,7 +481,6 @@ def build_follow_up_resolved_blocks( def build_slack_response_blocks( answer: ChatOnyxBotResponse, - tenant_id: str, message_info: SlackMessageInfo, channel_conf: ChannelConfig | None, use_citations: bool, @@ -517,7 +515,6 @@ def build_slack_response_blocks( if channel_conf and channel_conf.get("show_continue_in_web_ui"): web_follow_up_block.append( _build_continue_in_web_ui_block( - tenant_id=tenant_id, message_id=answer.chat_message_id, ) ) diff --git a/backend/onyx/onyxbot/slack/handlers/handle_buttons.py b/backend/onyx/onyxbot/slack/handlers/handle_buttons.py index 42428f231..548e3ebfc 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_buttons.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_buttons.py @@ -11,7 +11,7 @@ from onyx.configs.constants import SearchFeedbackType from onyx.configs.onyxbot_configs import DANSWER_FOLLOWUP_EMOJI from onyx.connectors.slack.utils import expert_info_from_slack_id from onyx.connectors.slack.utils import make_slack_api_rate_limited -from onyx.db.engine import get_session_with_tenant +from onyx.db.engine import get_session_with_current_tenant from onyx.db.feedback import create_chat_message_feedback from onyx.db.feedback import create_doc_retrieval_feedback from onyx.onyxbot.slack.blocks import build_follow_up_resolved_blocks @@ -114,7 +114,7 @@ def handle_generate_answer_button( thread_ts=thread_ts, ) - with get_session_with_tenant(tenant_id=client.tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: slack_channel_config = get_slack_channel_config_for_bot_and_channel( db_session=db_session, slack_bot_id=client.slack_bot_id, @@ -136,7 +136,6 @@ def handle_generate_answer_button( slack_channel_config=slack_channel_config, receiver_ids=None, client=client.web_client, - tenant_id=client.tenant_id, channel=channel_id, logger=logger, feedback_reminder_id=None, @@ -151,11 +150,10 @@ def handle_slack_feedback( user_id_to_post_confirmation: str, channel_id_to_post_confirmation: str, thread_ts_to_post_confirmation: str, - tenant_id: str, ) -> None: message_id, doc_id, doc_rank = decompose_action_id(feedback_id) - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: if feedback_type in [LIKE_BLOCK_ACTION_ID, DISLIKE_BLOCK_ACTION_ID]: create_chat_message_feedback( is_positive=feedback_type == LIKE_BLOCK_ACTION_ID, @@ -246,7 +244,7 @@ def handle_followup_button( tag_ids: list[str] = [] group_ids: list[str] = [] - with get_session_with_tenant(tenant_id=client.tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: channel_name, is_dm = get_channel_name_from_id( client=client.web_client, channel_id=channel_id ) diff --git a/backend/onyx/onyxbot/slack/handlers/handle_message.py b/backend/onyx/onyxbot/slack/handlers/handle_message.py index 3d38417e2..96e79cb45 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_message.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_message.py @@ -5,7 +5,7 @@ from slack_sdk.errors import SlackApiError from onyx.configs.onyxbot_configs import DANSWER_BOT_FEEDBACK_REMINDER from onyx.configs.onyxbot_configs import DANSWER_REACT_EMOJI -from onyx.db.engine import get_session_with_tenant +from onyx.db.engine import get_session_with_current_tenant from onyx.db.models import SlackChannelConfig from onyx.db.users import add_slack_user_if_not_exists from onyx.onyxbot.slack.blocks import get_feedback_reminder_blocks @@ -109,7 +109,6 @@ def handle_message( slack_channel_config: SlackChannelConfig, client: WebClient, feedback_reminder_id: str | None, - tenant_id: str, ) -> bool: """Potentially respond to the user message depending on filters and if an answer was generated @@ -135,9 +134,7 @@ def handle_message( action = "slack_tag_message" elif is_bot_dm: action = "slack_dm_message" - slack_usage_report( - action=action, sender_id=sender_id, client=client, tenant_id=tenant_id - ) + slack_usage_report(action=action, sender_id=sender_id, client=client) document_set_names: list[str] | None = None persona = slack_channel_config.persona if slack_channel_config else None @@ -218,7 +215,7 @@ def handle_message( except SlackApiError as e: logger.error(f"Was not able to react to user message due to: {e}") - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: if message_info.email: add_slack_user_if_not_exists(db_session, message_info.email) @@ -244,6 +241,5 @@ def handle_message( channel=channel, logger=logger, feedback_reminder_id=feedback_reminder_id, - tenant_id=tenant_id, ) return issue_with_regular_answer diff --git a/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py b/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py index f7c7d8f1a..73b123a32 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py @@ -24,7 +24,6 @@ from onyx.context.search.enums import OptionalSearchSetting from onyx.context.search.models import BaseFilters from onyx.context.search.models import RetrievalDetails from onyx.db.engine import get_session_with_current_tenant -from onyx.db.engine import get_session_with_tenant from onyx.db.models import SlackChannelConfig from onyx.db.models import User from onyx.db.persona import get_persona_by_id @@ -72,7 +71,6 @@ def handle_regular_answer( channel: str, logger: OnyxLoggingAdapter, feedback_reminder_id: str | None, - tenant_id: str, num_retries: int = DANSWER_BOT_NUM_RETRIES, thread_context_percent: float = MAX_THREAD_CONTEXT_PERCENTAGE, should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS, @@ -87,7 +85,7 @@ def handle_regular_answer( user = None if message_info.is_bot_dm: if message_info.email: - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: user = get_user_by_email(message_info.email, db_session) document_set_names: list[str] | None = None @@ -96,7 +94,7 @@ def handle_regular_answer( # This way slack flow always has a persona persona = slack_channel_config.persona if not persona: - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: persona = get_persona_by_id(DEFAULT_PERSONA_ID, user, db_session) document_set_names = [ document_set.name for document_set in persona.document_sets @@ -157,7 +155,7 @@ def handle_regular_answer( def _get_slack_answer( new_message_request: CreateChatMessageRequest, onyx_user: User | None ) -> ChatOnyxBotResponse: - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: packets = stream_chat_message_objects( new_msg_req=new_message_request, user=onyx_user, @@ -197,7 +195,7 @@ def handle_regular_answer( enable_auto_detect_filters=auto_detect_filters, ) - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: answer_request = prepare_chat_message_request( message_text=user_message.message, user=user, @@ -361,7 +359,6 @@ def handle_regular_answer( return True all_blocks = build_slack_response_blocks( - tenant_id=tenant_id, message_info=message_info, answer=answer, channel_conf=channel_conf, diff --git a/backend/onyx/onyxbot/slack/listener.py b/backend/onyx/onyxbot/slack/listener.py index 6fb590622..2cec1a66e 100644 --- a/backend/onyx/onyxbot/slack/listener.py +++ b/backend/onyx/onyxbot/slack/listener.py @@ -37,6 +37,7 @@ from onyx.context.search.retrieval.search_runner import ( download_nltk_data, ) from onyx.db.engine import get_all_tenant_ids +from onyx.db.engine import get_session_with_current_tenant from onyx.db.engine import get_session_with_tenant from onyx.db.models import SlackBot from onyx.db.search_settings import get_current_search_settings @@ -92,6 +93,7 @@ from shared_configs.configs import MODEL_SERVER_PORT from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA from shared_configs.configs import SLACK_CHANNEL_ID from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR +from shared_configs.contextvars import get_current_tenant_id logger = setup_logger() @@ -347,7 +349,7 @@ class SlackbotHandler: redis_client = get_redis_client(tenant_id=tenant_id) try: - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: # Attempt to fetch Slack bots try: bots = list(fetch_slack_bots(db_session=db_session)) @@ -586,7 +588,7 @@ def prefilter_requests(req: SocketModeRequest, client: TenantSocketModeClient) - channel_name, _ = get_channel_name_from_id( client=client.web_client, channel_id=channel ) - with get_session_with_tenant(tenant_id=client.tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: slack_channel_config = get_slack_channel_config_for_bot_and_channel( db_session=db_session, slack_bot_id=client.slack_bot_id, @@ -680,7 +682,6 @@ def process_feedback(req: SocketModeRequest, client: TenantSocketModeClient) -> user_id_to_post_confirmation=user_id, channel_id_to_post_confirmation=channel_id, thread_ts_to_post_confirmation=thread_ts, - tenant_id=client.tenant_id, ) query_event_id, _, _ = decompose_action_id(feedback_id) @@ -796,8 +797,9 @@ def process_message( respond_every_channel: bool = DANSWER_BOT_RESPOND_EVERY_CHANNEL, notify_no_answer: bool = NOTIFY_SLACKBOT_NO_ANSWER, ) -> None: + tenant_id = get_current_tenant_id() logger.debug( - f"Received Slack request of type: '{req.type}' for tenant, {client.tenant_id}" + f"Received Slack request of type: '{req.type}' for tenant, {tenant_id}" ) # Throw out requests that can't or shouldn't be handled @@ -810,50 +812,39 @@ def process_message( client=client.web_client, channel_id=channel ) - token: Token[str | None] | None = None - # Set the current tenant ID at the beginning for all DB calls within this thread - if client.tenant_id: - logger.info(f"Setting tenant ID to {client.tenant_id}") - token = CURRENT_TENANT_ID_CONTEXTVAR.set(client.tenant_id) - try: - with get_session_with_tenant(tenant_id=client.tenant_id) as db_session: - slack_channel_config = get_slack_channel_config_for_bot_and_channel( - db_session=db_session, - slack_bot_id=client.slack_bot_id, - channel_name=channel_name, - ) + with get_session_with_current_tenant() as db_session: + slack_channel_config = get_slack_channel_config_for_bot_and_channel( + db_session=db_session, + slack_bot_id=client.slack_bot_id, + channel_name=channel_name, + ) - follow_up = bool( - slack_channel_config.channel_config - and slack_channel_config.channel_config.get("follow_up_tags") - is not None - ) + follow_up = bool( + slack_channel_config.channel_config + and slack_channel_config.channel_config.get("follow_up_tags") is not None + ) - feedback_reminder_id = schedule_feedback_reminder( - details=details, client=client.web_client, include_followup=follow_up - ) + feedback_reminder_id = schedule_feedback_reminder( + details=details, client=client.web_client, include_followup=follow_up + ) - failed = handle_message( - message_info=details, - slack_channel_config=slack_channel_config, - client=client.web_client, - feedback_reminder_id=feedback_reminder_id, - tenant_id=client.tenant_id, - ) + failed = handle_message( + message_info=details, + slack_channel_config=slack_channel_config, + client=client.web_client, + feedback_reminder_id=feedback_reminder_id, + ) - if failed: - if feedback_reminder_id: - remove_scheduled_feedback_reminder( - client=client.web_client, - channel=details.sender_id, - msg_id=feedback_reminder_id, - ) - # Skipping answering due to pre-filtering is not considered a failure - if notify_no_answer: - apologize_for_fail(details, client) - finally: - if token: - CURRENT_TENANT_ID_CONTEXTVAR.reset(token) + if failed: + if feedback_reminder_id: + remove_scheduled_feedback_reminder( + client=client.web_client, + channel=details.sender_id, + msg_id=feedback_reminder_id, + ) + # Skipping answering due to pre-filtering is not considered a failure + if notify_no_answer: + apologize_for_fail(details, client) def acknowledge_message(req: SocketModeRequest, client: TenantSocketModeClient) -> None: diff --git a/backend/onyx/onyxbot/slack/utils.py b/backend/onyx/onyxbot/slack/utils.py index f5d2209b0..1f85ca2da 100644 --- a/backend/onyx/onyxbot/slack/utils.py +++ b/backend/onyx/onyxbot/slack/utils.py @@ -4,6 +4,8 @@ import re import string import time import uuid +from collections.abc import Generator +from contextlib import contextmanager from typing import Any from typing import cast @@ -30,7 +32,7 @@ from onyx.configs.onyxbot_configs import ( ) from onyx.connectors.slack.utils import make_slack_api_rate_limited from onyx.connectors.slack.utils import SlackTextCleaner -from onyx.db.engine import get_session_with_tenant +from onyx.db.engine import get_session_with_current_tenant from onyx.db.users import get_user_by_email from onyx.llm.exceptions import GenAIDisabledException from onyx.llm.factory import get_default_llms @@ -43,6 +45,7 @@ from onyx.utils.logger import setup_logger from onyx.utils.telemetry import optional_telemetry from onyx.utils.telemetry import RecordType from onyx.utils.text_processing import replace_whitespaces_w_space +from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR logger = setup_logger() @@ -569,9 +572,7 @@ def read_slack_thread( return thread_messages -def slack_usage_report( - action: str, sender_id: str | None, client: WebClient, tenant_id: str -) -> None: +def slack_usage_report(action: str, sender_id: str | None, client: WebClient) -> None: if DISABLE_TELEMETRY: return @@ -583,14 +584,13 @@ def slack_usage_report( logger.warning("Unable to find sender email") if sender_email is not None: - with get_session_with_tenant(tenant_id=tenant_id) as db_session: + with get_session_with_current_tenant() as db_session: onyx_user = get_user_by_email(email=sender_email, db_session=db_session) optional_telemetry( record_type=RecordType.USAGE, data={"action": action}, user_id=str(onyx_user.id) if onyx_user else "Non-Onyx-Or-No-Auth-User", - tenant_id=tenant_id, ) @@ -665,5 +665,28 @@ def get_feedback_visibility() -> FeedbackVisibility: class TenantSocketModeClient(SocketModeClient): def __init__(self, tenant_id: str, slack_bot_id: int, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) - self.tenant_id = tenant_id + self._tenant_id = tenant_id self.slack_bot_id = slack_bot_id + + @contextmanager + def _set_tenant_context(self) -> Generator[None, None, None]: + token = None + try: + if self._tenant_id: + token = CURRENT_TENANT_ID_CONTEXTVAR.set(self._tenant_id) + yield + finally: + if token: + CURRENT_TENANT_ID_CONTEXTVAR.reset(token) + + def enqueue_message(self, message: str) -> None: + with self._set_tenant_context(): + super().enqueue_message(message) + + def process_message(self) -> None: + with self._set_tenant_context(): + super().process_message() + + def run_message_listeners(self, message: dict, raw_message: str) -> None: + with self._set_tenant_context(): + super().run_message_listeners(message, raw_message) From 338e0840626e8368621299647bf33c6e15374bad Mon Sep 17 00:00:00 2001 From: pablonyx Date: Wed, 26 Feb 2025 20:06:26 -0800 Subject: [PATCH 08/10] Improved tenant handling for slack bot (#4099) --- .../app/chat/input-prompts/InputPrompts.tsx | 252 ++++++++++-------- web/src/app/chat/input-prompts/PromptCard.tsx | 147 ++++++++++ web/src/app/chat/input/ChatInputBar.tsx | 1 - web/src/app/chat/modal/UserSettingsModal.tsx | 30 ++- web/src/components/user/UserProvider.tsx | 4 +- web/src/lib/types.ts | 2 +- 6 files changed, 311 insertions(+), 125 deletions(-) create mode 100644 web/src/app/chat/input-prompts/PromptCard.tsx diff --git a/web/src/app/chat/input-prompts/InputPrompts.tsx b/web/src/app/chat/input-prompts/InputPrompts.tsx index 0cf6ce546..02dd93bb8 100644 --- a/web/src/app/chat/input-prompts/InputPrompts.tsx +++ b/web/src/app/chat/input-prompts/InputPrompts.tsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { InputPrompt } from "@/app/chat/interfaces"; import { Button } from "@/components/ui/button"; -import { TrashIcon, PlusIcon } from "@/components/icons/icons"; -import { MoreVertical, CheckIcon, XIcon } from "lucide-react"; +import { PlusIcon } from "@/components/icons/icons"; +import { MoreVertical, XIcon } from "lucide-react"; import { Textarea } from "@/components/ui/textarea"; import Title from "@/components/ui/title"; import Text from "@/components/ui/text"; @@ -153,114 +153,6 @@ export default function InputPrompts() { } }; - const PromptCard = ({ prompt }: { prompt: InputPrompt }) => { - const isEditing = editingPromptId === prompt.id; - const [localPrompt, setLocalPrompt] = useState(prompt.prompt); - const [localContent, setLocalContent] = useState(prompt.content); - - // Sync local edits with any prompt changes from outside - useEffect(() => { - setLocalPrompt(prompt.prompt); - setLocalContent(prompt.content); - }, [prompt, isEditing]); - - const handleLocalEdit = (field: "prompt" | "content", value: string) => { - if (field === "prompt") { - setLocalPrompt(value); - } else { - setLocalContent(value); - } - }; - - const handleSaveLocal = () => { - handleSave(prompt.id, localPrompt, localContent); - }; - - return ( -
    - {isEditing ? ( - <> -
    - -
    -
    -
    -