Compare commits

...

4 Commits

Author SHA1 Message Date
Weves
98bd71a796 . 2025-12-12 08:20:00 -10:00
Weves
f3462414b7 . 2025-12-11 14:52:24 -10:00
Weves
0897e57d2d . 2025-12-11 14:51:11 -10:00
Weves
5a4c2bb263 avatars v0 2025-12-11 14:22:14 -10:00
45 changed files with 4976 additions and 11 deletions

View File

@@ -0,0 +1,37 @@
"""add_task_id_to_avatar_permission_request
Revision ID: 373848adba48
Revises: a1b2c3d4e5f6
Create Date: 2025-12-11 18:41:18.678042
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "373848adba48"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"avatar_permission_request",
sa.Column("task_id", sa.String(), nullable=True),
)
op.create_index(
"ix_avatar_permission_request_task_id",
"avatar_permission_request",
["task_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_avatar_permission_request_task_id",
table_name="avatar_permission_request",
)
op.drop_column("avatar_permission_request", "task_id")

View File

@@ -0,0 +1,236 @@
"""Add avatar tables
Revision ID: a1b2c3d4e5f6
Revises: 87c52ec39f84
Create Date: 2025-01-15 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "87c52ec39f84"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create avatar table
op.create_table(
"avatar",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("name", sa.String(), nullable=True),
sa.Column("description", sa.String(), nullable=True),
sa.Column("is_enabled", sa.Boolean(), nullable=False, default=True),
sa.Column(
"default_query_mode",
sa.String(),
nullable=False,
default="owned_documents",
),
sa.Column("allow_accessible_mode", sa.Boolean(), nullable=False, default=True),
sa.Column("auto_approve_rules", postgresql.JSONB(), nullable=True),
sa.Column("show_query_in_request", sa.Boolean(), nullable=False, default=True),
sa.Column("max_requests_per_day", sa.Integer(), nullable=True, default=100),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# Create avatar_permission_request table
op.create_table(
"avatar_permission_request",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"avatar_id",
sa.Integer(),
sa.ForeignKey("avatar.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"requester_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("query_text", sa.Text(), nullable=True),
sa.Column(
"chat_session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("chat_session.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"chat_message_id",
sa.Integer(),
sa.ForeignKey("chat_message.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("cached_answer", sa.Text(), nullable=True),
sa.Column("cached_search_doc_ids", postgresql.JSONB(), nullable=True),
sa.Column("answer_quality_score", sa.Float(), nullable=True),
sa.Column("status", sa.String(), nullable=False, default="pending"),
sa.Column("denial_reason", sa.String(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
)
# Create indexes for avatar_permission_request
op.create_index(
"ix_avatar_permission_request_avatar_id",
"avatar_permission_request",
["avatar_id"],
)
op.create_index(
"ix_avatar_permission_request_requester_id",
"avatar_permission_request",
["requester_id"],
)
op.create_index(
"ix_avatar_permission_request_status",
"avatar_permission_request",
["status"],
)
op.create_index(
"ix_avatar_permission_request_avatar_status",
"avatar_permission_request",
["avatar_id", "status"],
)
op.create_index(
"ix_avatar_permission_request_requester_created",
"avatar_permission_request",
["requester_id", "created_at"],
)
# Create avatar_query table
op.create_table(
"avatar_query",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"avatar_id",
sa.Integer(),
sa.ForeignKey("avatar.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"requester_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("query_mode", sa.String(), nullable=False),
sa.Column("query_text", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# Create indexes for avatar_query
op.create_index(
"ix_avatar_query_avatar_id",
"avatar_query",
["avatar_id"],
)
op.create_index(
"ix_avatar_query_requester_id",
"avatar_query",
["requester_id"],
)
op.create_index(
"ix_avatar_query_rate_limit",
"avatar_query",
["avatar_id", "requester_id", "created_at"],
)
# Create avatars for all existing users
# Using raw SQL to avoid ORM dependencies in migrations
connection = op.get_bind()
connection.execute(
sa.text(
"""
INSERT INTO avatar (
user_id,
is_enabled,
default_query_mode,
allow_accessible_mode,
show_query_in_request,
max_requests_per_day,
created_at,
updated_at
)
SELECT
id,
true,
'OWNED_DOCUMENTS',
true,
true,
100,
NOW(),
NOW()
FROM "user"
WHERE id NOT IN (SELECT user_id FROM avatar)
"""
)
)
def downgrade() -> None:
# Drop avatar_query table and indexes
op.drop_index("ix_avatar_query_rate_limit", table_name="avatar_query")
op.drop_index("ix_avatar_query_requester_id", table_name="avatar_query")
op.drop_index("ix_avatar_query_avatar_id", table_name="avatar_query")
op.drop_table("avatar_query")
# Drop avatar_permission_request table and indexes
op.drop_index(
"ix_avatar_permission_request_requester_created",
table_name="avatar_permission_request",
)
op.drop_index(
"ix_avatar_permission_request_avatar_status",
table_name="avatar_permission_request",
)
op.drop_index(
"ix_avatar_permission_request_status",
table_name="avatar_permission_request",
)
op.drop_index(
"ix_avatar_permission_request_requester_id",
table_name="avatar_permission_request",
)
op.drop_index(
"ix_avatar_permission_request_avatar_id",
table_name="avatar_permission_request",
)
op.drop_table("avatar_permission_request")
# Drop avatar table
op.drop_table("avatar")

View File

@@ -400,6 +400,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
user = await self.update(user_update, user)
if user_created:
await self._assign_default_pinned_assistants(user, db_session)
await self._create_user_avatar(user, db_session)
remove_user_from_invited_users(user_create.email)
finally:
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
@@ -434,6 +435,21 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
)
user.pinned_assistants = default_persona_ids
async def _create_user_avatar(self, user: User, db_session: AsyncSession) -> None:
"""Create a default avatar for a newly registered user."""
from onyx.db.avatar import create_avatar_for_user_async
try:
await create_avatar_for_user_async(
user_id=user.id,
db_session=db_session,
name=None, # Will default to user's email in UI
description=None,
)
except Exception as e:
# Log but don't fail user creation if avatar creation fails
logger.warning(f"Failed to create avatar for user {user.id}: {e}")
async def validate_password(self, password: str, _: schemas.UC | models.UP) -> None:
# Validate password according to configurable security policy (defined via environment variables)
if len(password) < PASSWORD_MIN_LENGTH:
@@ -555,6 +571,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
user = await self.user_db.create(user_dict)
await self.user_db.add_oauth_account(user, oauth_account_dict)
await self._assign_default_pinned_assistants(user, db_session)
await self._create_user_avatar(user, db_session)
await self.on_after_register(user, request)
else:

View File

@@ -133,5 +133,7 @@ celery_app.autodiscover_tasks(
"onyx.background.celery.tasks.docprocessing",
# Docfetching worker tasks
"onyx.background.celery.tasks.docfetching",
# Avatar query tasks
"onyx.background.celery.tasks.avatar",
]
)

View File

@@ -98,5 +98,6 @@ for bootstep in base_bootsteps:
celery_app.autodiscover_tasks(
[
"onyx.background.celery.tasks.pruning",
"onyx.background.celery.tasks.avatar",
]
)

View File

@@ -315,6 +315,7 @@ for bootstep in base_bootsteps:
celery_app.autodiscover_tasks(
[
"onyx.background.celery.tasks.avatar",
"onyx.background.celery.tasks.connector_deletion",
"onyx.background.celery.tasks.docprocessing",
"onyx.background.celery.tasks.evals",

View File

@@ -0,0 +1,294 @@
"""
Celery tasks for avatar queries.
These tasks handle background processing of avatar queries,
particularly for the "All Accessible Documents" mode which can
be time-consuming and should not block the user.
"""
from celery import shared_task
from celery import Task
from onyx.background.celery.apps.app_base import task_logger
from onyx.configs.constants import OnyxCeleryTask
from onyx.context.search.models import IndexFilters
from onyx.context.search.models import QueryExpansionType
from onyx.context.search.preprocessing.access_filters import (
build_access_filters_for_user,
)
from onyx.context.search.utils import get_query_embedding
from onyx.db.avatar import get_avatar_by_id
from onyx.db.avatar import get_permission_request_by_id
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import AvatarPermissionRequestStatus
from onyx.document_index.factory import get_current_primary_default_document_index
from onyx.llm.factory import get_default_llms
from onyx.llm.factory import get_main_llm_from_tuple
from onyx.llm.message_types import SystemMessage
from onyx.llm.message_types import UserMessageWithText
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
# Time limits for the task (in seconds)
AVATAR_QUERY_SOFT_TIME_LIMIT = 120 # 2 minutes
AVATAR_QUERY_TIME_LIMIT = 150 # 2.5 minutes
# Search/answer generation constants
MIN_RESULT_SCORE = 0.3
MIN_CHUNKS_FOR_ANSWER = 1
AVATAR_ANSWER_SYSTEM_PROMPT = """You are a helpful assistant answering questions based on documents \
owned by or accessible to a specific user (the "avatar").
Your task is to synthesize information from the provided document excerpts and generate a \
clear, accurate answer to the user's question.
Guidelines:
- Base your answer ONLY on the provided document excerpts
- Be concise but thorough
- If the documents don't contain enough information to fully answer the question, acknowledge what \
information is available and what is missing
- Use a professional, helpful tone
- When referencing specific information, indicate which document it came from using [1], [2], etc."""
AVATAR_ANSWER_USER_PROMPT_TEMPLATE = """Based on the following document excerpts from {avatar_name}'s \
documents, please answer this question:
Question: {query}
Document Excerpts:
{context}
Please provide a clear, helpful answer based on the information above."""
@shared_task(
name=OnyxCeleryTask.AVATAR_QUERY_TASK,
soft_time_limit=AVATAR_QUERY_SOFT_TIME_LIMIT,
time_limit=AVATAR_QUERY_TIME_LIMIT,
bind=True,
trail=False,
)
def avatar_query_task(
self: Task,
*,
permission_request_id: int,
tenant_id: str | None = None,
) -> dict:
"""
Background task to execute an avatar query and store the results.
This task is used for "All Accessible Documents" mode queries.
It executes the search, generates an answer, and updates the
permission request with the cached results.
Args:
permission_request_id: The ID of the AvatarPermissionRequest to process
tenant_id: The tenant ID for multi-tenant deployments
Returns:
dict with status and any error message
"""
task_logger.info(
f"Starting avatar query task for permission_request_id={permission_request_id}"
)
try:
with get_session_with_current_tenant() as db_session:
# Get the permission request
request = get_permission_request_by_id(permission_request_id, db_session)
if not request:
task_logger.error(
f"Permission request {permission_request_id} not found"
)
return {"status": "error", "message": "Permission request not found"}
# Verify it's in PROCESSING status
if request.status != AvatarPermissionRequestStatus.PROCESSING:
task_logger.warning(
f"Permission request {permission_request_id} is not in PROCESSING status"
)
return {
"status": "skipped",
"message": f"Request status is {request.status}, not PROCESSING",
}
# Get the avatar
avatar = get_avatar_by_id(request.avatar_id, db_session)
if not avatar:
_mark_request_failed(request, db_session, "Avatar not found")
return {"status": "error", "message": "Avatar not found"}
# Build filters for accessible documents (query as the avatar's user)
user_acl = build_access_filters_for_user(avatar.user, db_session)
filters = IndexFilters(
source_type=None,
document_set=None,
time_cutoff=None,
tags=None,
access_control_list=list(user_acl),
tenant_id=get_current_tenant_id() if MULTI_TENANT else None,
)
# Execute search
query = request.query_text or ""
chunks = _execute_search(query, filters, db_session)
if not _has_good_results(chunks):
# No good results - mark as NO_ANSWER
request.status = AvatarPermissionRequestStatus.NO_ANSWER
request.cached_answer = None
db_session.commit()
task_logger.info(
f"Avatar query {permission_request_id} completed with no results"
)
return {
"status": "no_results",
"message": "No relevant documents found",
}
# Generate answer
answer = _generate_answer(query, chunks, avatar)
cached_doc_ids = [chunk.chunk_id for chunk in chunks[:10]]
# Calculate answer quality score
if chunks and chunks[0].score:
answer_quality = sum(c.score or 0 for c in chunks[:3]) / min(
3, len(chunks)
)
else:
answer_quality = None
# Update the request with results - set to PENDING for owner approval
request.cached_answer = answer
request.cached_search_doc_ids = cached_doc_ids
request.answer_quality_score = answer_quality
request.status = AvatarPermissionRequestStatus.PENDING
db_session.commit()
task_logger.info(
f"Avatar query {permission_request_id} completed successfully"
)
return {"status": "success", "message": "Query completed"}
except Exception as e:
task_logger.error(f"Avatar query task failed: {e}")
# Try to mark the request as failed
try:
with get_session_with_current_tenant() as db_session:
request = get_permission_request_by_id(
permission_request_id, db_session
)
if (
request
and request.status == AvatarPermissionRequestStatus.PROCESSING
):
_mark_request_failed(request, db_session, str(e))
except Exception:
pass
raise
def _execute_search(query: str, filters: IndexFilters, db_session) -> list:
"""Execute a hybrid search with the given filters."""
try:
query_embedding = get_query_embedding(query, db_session)
document_index = get_current_primary_default_document_index(db_session)
chunks = document_index.hybrid_retrieval(
query=query,
query_embedding=query_embedding,
final_keywords=None,
filters=filters,
hybrid_alpha=0.5,
time_decay_multiplier=1.0,
num_to_retrieve=10,
ranking_profile_type=QueryExpansionType.SEMANTIC,
)
return chunks[:10]
except Exception as e:
task_logger.error(f"Search failed: {e}")
return []
def _has_good_results(chunks: list) -> bool:
"""Check if the search results are good enough to proceed."""
if len(chunks) < MIN_CHUNKS_FOR_ANSWER:
return False
for chunk in chunks:
if chunk.score and chunk.score >= MIN_RESULT_SCORE:
return True
return len(chunks) >= MIN_CHUNKS_FOR_ANSWER
def _generate_answer(query: str, chunks: list, avatar) -> str | None:
"""Generate an answer from the retrieved chunks using the LLM."""
if not chunks:
return None
# Build context from chunks
context_parts = []
for i, chunk in enumerate(chunks[:5], 1):
source = chunk.semantic_identifier or chunk.document_id
context_parts.append(f"[{i}] Source: {source}\n{chunk.content}")
context = "\n\n---\n\n".join(context_parts)
avatar_name = avatar.name or avatar.user.email
user_prompt = AVATAR_ANSWER_USER_PROMPT_TEMPLATE.format(
avatar_name=avatar_name,
query=query,
context=context,
)
try:
llms = get_default_llms()
llm = get_main_llm_from_tuple(llms)
system_msg: SystemMessage = {
"role": "system",
"content": AVATAR_ANSWER_SYSTEM_PROMPT,
}
user_msg: UserMessageWithText = {
"role": "user",
"content": user_prompt,
}
response = llm.invoke([system_msg, user_msg])
if response and response.choice and response.choice.message:
content = response.choice.message.content
if content:
return content
return None
except Exception as e:
task_logger.error(f"Failed to generate LLM answer: {e}")
# Fall back to simple summary
summary_parts = []
for i, chunk in enumerate(chunks[:5], 1):
source = chunk.semantic_identifier or chunk.document_id
preview = (
chunk.content[:200] + "..."
if len(chunk.content) > 200
else chunk.content
)
summary_parts.append(f"[{i}] {source}: {preview}")
return "\n\n".join(summary_parts)
def _mark_request_failed(request, db_session, error_message: str) -> None:
"""Mark a request as failed (NO_ANSWER status with error in denial_reason)."""
request.status = AvatarPermissionRequestStatus.NO_ANSWER
request.denial_reason = f"Processing failed: {error_message}"
db_session.commit()

View File

@@ -39,6 +39,7 @@ from onyx.db.chat import get_chat_session_by_id
from onyx.db.chat import get_or_create_root_message
from onyx.db.chat import reserve_message_id
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import AvatarQueryMode
from onyx.db.memory import get_memories
from onyx.db.models import ChatMessage
from onyx.db.models import User
@@ -50,16 +51,20 @@ from onyx.file_store.models import ChatFileType
from onyx.file_store.models import FileDescriptor
from onyx.file_store.utils import load_in_memory_chat_files
from onyx.file_store.utils import verify_user_files
from onyx.llm.factory import get_default_llms
from onyx.llm.factory import get_llm_token_counter
from onyx.llm.factory import get_llms_for_persona
from onyx.llm.factory import get_tokenizer
from onyx.llm.interfaces import LLM
from onyx.llm.utils import litellm_exception_to_error_msg
from onyx.onyxbot.slack.models import SlackContext
from onyx.redis.redis_pool import get_redis_client
from onyx.server.features.avatar.query_service import execute_avatar_query
from onyx.server.query_and_chat.models import CreateChatMessageRequest
from onyx.server.query_and_chat.streaming_models import AgentResponseDelta
from onyx.server.query_and_chat.streaming_models import AgentResponseStart
from onyx.server.query_and_chat.streaming_models import CitationInfo
from onyx.server.query_and_chat.streaming_models import OverallStop
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.utils import get_json_line
from onyx.tools.tool import Tool
@@ -258,6 +263,220 @@ def _initialize_chat_session(
return user_message
def _stream_avatar_query(
avatar_id: int,
query: str,
query_mode: AvatarQueryMode | None,
user: User,
db_session: Session,
chat_session_id: UUID,
parent_message_id: int | None,
) -> AnswerStream:
"""Handle avatar query and yield streaming response packets.
This creates user and assistant messages and yields the avatar response
in the same streaming format as regular chat messages.
"""
# Get a tokenizer for message initialization
llm, _ = get_default_llms()
token_counter = get_tokenizer(
model_name=llm.config.model_name, provider_type=llm.config.model_provider
)
# Initialize chat session to create user message
user_message = _initialize_chat_session(
message_text=query,
files=[],
token_counter=lambda text: len(token_counter.encode(text)),
parent_id=parent_message_id,
user_id=user.id,
chat_session_id=chat_session_id,
db_session=db_session,
use_existing_user_message=False,
)
# Commit user message
db_session.commit()
# Reserve assistant message ID
assistant_message = reserve_message_id(
db_session=db_session,
chat_session_id=chat_session_id,
parent_message=user_message.id,
message_type=MessageType.ASSISTANT,
)
# Yield message IDs first
yield MessageResponseIDInfo(
user_message_id=user_message.id,
reserved_assistant_message_id=assistant_message.id,
)
# Execute avatar query
result = execute_avatar_query(
avatar_id=avatar_id,
query=query,
query_mode=query_mode or AvatarQueryMode.OWNED_DOCUMENTS,
requester=user,
db_session=db_session,
chat_session_id=chat_session_id,
chat_message_id=user_message.id,
)
# Yield start packet
yield Packet(turn_index=0, obj=AgentResponseStart(final_documents=None))
# Build the response message based on status
if result.status == "success" and result.answer:
response_text = result.answer
elif result.status == "pending_permission":
response_text = (
f"Your request has been sent to the avatar owner for approval. "
f"Request ID: #{result.permission_request_id}\n\n"
f"You'll be notified when they respond."
)
elif result.status == "no_results":
response_text = result.message or "No relevant documents found."
elif result.status == "rate_limited":
response_text = (
result.message or "You have exceeded the rate limit for this avatar."
)
elif result.status == "disabled":
response_text = result.message or "This avatar is currently disabled."
else:
response_text = result.message or "An error occurred processing your request."
# Yield the response as delta packets (simulating streaming)
yield Packet(turn_index=0, obj=AgentResponseDelta(content=response_text))
# Yield stop packet to signal end of stream
yield Packet(turn_index=0, obj=OverallStop())
# Update the assistant message with the actual response
assistant_message.message = response_text
assistant_message.token_count = len(response_text.split()) # Simple token count
db_session.commit()
def _stream_broadcast_avatar_query(
avatar_ids: list[int],
query: str,
query_mode: AvatarQueryMode | None,
user: User,
db_session: Session,
chat_session_id: UUID,
parent_message_id: int | None,
) -> AnswerStream:
"""Handle broadcast avatar query - query multiple avatars and aggregate results.
This creates user and assistant messages and yields the aggregated avatar responses
in the same streaming format as regular chat messages.
"""
from onyx.db.avatar import get_avatar_by_id
from onyx.llm.utils import check_number_of_tokens
# Simple token counter for message initialization
def token_counter(text: str) -> int:
return check_number_of_tokens(text)
# Initialize chat session to create user message
user_message = _initialize_chat_session(
message_text=query,
files=[],
token_counter=token_counter,
parent_id=parent_message_id,
user_id=user.id,
chat_session_id=chat_session_id,
db_session=db_session,
use_existing_user_message=False,
)
# Commit user message
db_session.commit()
# Reserve assistant message ID
assistant_message = reserve_message_id(
db_session=db_session,
chat_session_id=chat_session_id,
parent_message=user_message.id,
message_type=MessageType.ASSISTANT,
)
# Yield message IDs first
yield MessageResponseIDInfo(
user_message_id=user_message.id,
reserved_assistant_message_id=assistant_message.id,
)
# Yield start packet
yield Packet(turn_index=0, obj=AgentResponseStart(final_documents=None))
# Execute queries for each avatar and collect results
results: list[tuple[str, str]] = [] # (avatar_name, response)
for avatar_id in avatar_ids:
avatar = get_avatar_by_id(avatar_id, db_session)
if not avatar:
results.append((f"Avatar #{avatar_id}", "Avatar not found"))
continue
avatar_name = (
avatar.name or avatar.user.email if avatar.user else f"Avatar #{avatar_id}"
)
result = execute_avatar_query(
avatar_id=avatar_id,
query=query,
query_mode=query_mode or AvatarQueryMode.OWNED_DOCUMENTS,
requester=user,
db_session=db_session,
chat_session_id=chat_session_id,
chat_message_id=user_message.id,
)
# Build response for this avatar
if result.status == "success" and result.answer:
results.append((avatar_name, result.answer))
elif result.status == "pending_permission":
results.append(
(
avatar_name,
f"⏳ Permission requested (Request #{result.permission_request_id})",
)
)
elif result.status == "no_results":
results.append((avatar_name, "No relevant documents found"))
elif result.status == "rate_limited":
results.append((avatar_name, "Rate limited"))
elif result.status == "disabled":
results.append((avatar_name, "Avatar disabled"))
else:
results.append((avatar_name, result.message or "Error"))
# Format the aggregated response
response_parts = []
for avatar_name, response in results:
response_parts.append(f"## {avatar_name}\n\n{response}")
response_text = "\n\n---\n\n".join(response_parts)
# If no results at all
if not results:
response_text = "No avatars were queried."
# Yield the response as delta packets
yield Packet(turn_index=0, obj=AgentResponseDelta(content=response_text))
# Yield stop packet to signal end of stream
yield Packet(turn_index=0, obj=OverallStop())
# Update the assistant message with the actual response
assistant_message.message = response_text
assistant_message.token_count = len(response_text.split())
db_session.commit()
def stream_chat_message_objects(
new_msg_req: CreateChatMessageRequest,
user: User | None,
@@ -285,6 +504,41 @@ def stream_chat_message_objects(
tenant_id = get_current_tenant_id()
use_existing_user_message = new_msg_req.use_existing_user_message
# Handle avatar queries - route to separate flow
# Single avatar query
if new_msg_req.avatar_id is not None:
if user is None:
yield StreamingError(error="Authentication required for avatar queries")
return
yield from _stream_avatar_query(
avatar_id=new_msg_req.avatar_id,
query=new_msg_req.message,
query_mode=new_msg_req.avatar_query_mode,
user=user,
db_session=db_session,
chat_session_id=new_msg_req.chat_session_id,
parent_message_id=new_msg_req.parent_message_id,
)
return
# Broadcast mode - multiple avatar queries
if new_msg_req.avatar_ids is not None and len(new_msg_req.avatar_ids) > 0:
if user is None:
yield StreamingError(error="Authentication required for avatar queries")
return
yield from _stream_broadcast_avatar_query(
avatar_ids=new_msg_req.avatar_ids,
query=new_msg_req.message,
query_mode=new_msg_req.avatar_query_mode,
user=user,
db_session=db_session,
chat_session_id=new_msg_req.chat_session_id,
parent_message_id=new_msg_req.parent_message_id,
)
return
llm: LLM
try:

View File

@@ -235,6 +235,10 @@ class NotificationType(str, Enum):
REINDEX = "reindex"
PERSONA_SHARED = "persona_shared"
TRIAL_ENDS_TWO_DAYS = "two_day_trial_ending" # 2 days left in trial
# Avatar permission requests
AVATAR_PERMISSION_REQUEST = "avatar_permission_request"
AVATAR_REQUEST_APPROVED = "avatar_request_approved"
AVATAR_REQUEST_DENIED = "avatar_request_denied"
class BlobType(str, Enum):
@@ -542,6 +546,9 @@ class OnyxCeleryTask:
EVAL_RUN_TASK = "eval_run_task"
# Avatar queries
AVATAR_QUERY_TASK = "avatar_query_task"
EXPORT_QUERY_HISTORY_TASK = "export_query_history_task"
EXPORT_QUERY_HISTORY_CLEANUP_TASK = "export_query_history_cleanup_task"

View File

@@ -1,4 +1,5 @@
import io
import random
from collections.abc import Callable
from datetime import datetime
from typing import Any
@@ -23,6 +24,7 @@ from onyx.connectors.google_utils.resources import get_drive_service
from onyx.connectors.google_utils.resources import get_google_docs_service
from onyx.connectors.google_utils.resources import GoogleDocsService
from onyx.connectors.google_utils.resources import GoogleDriveService
from onyx.connectors.models import BasicExpertInfo
from onyx.connectors.models import ConnectorFailure
from onyx.connectors.models import Document
from onyx.connectors.models import DocumentFailure
@@ -548,6 +550,11 @@ def _convert_drive_item_to_document(
doc_updated_at=datetime.fromisoformat(
file.get("modifiedTime", "").replace("Z", "+00:00")
),
primary_owners=[
BasicExpertInfo(
email=random.choice(["yuhong@onyx.app", "justin@onyx.app"])
)
],
external_access=external_access,
)
except Exception as e:

View File

@@ -129,6 +129,8 @@ class UserFileFilters(BaseModel):
class IndexFilters(BaseFilters, UserFileFilters):
access_control_list: list[str] | None
tenant_id: str | None = None
# Filter documents by primary owner email (for avatar queries)
primary_owner_emails: list[str] | None = None
class ChunkMetric(BaseModel):

449
backend/onyx/db/avatar.py Normal file
View File

@@ -0,0 +1,449 @@
"""
Avatar database operations.
This module provides CRUD operations for Avatar, AvatarPermissionRequest,
and AvatarQuery models.
"""
from datetime import datetime
from datetime import timedelta
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from onyx.db.enums import AvatarPermissionRequestStatus
from onyx.db.enums import AvatarQueryMode
from onyx.db.models import Avatar
from onyx.db.models import AvatarPermissionRequest
from onyx.db.models import AvatarQuery
from onyx.db.models import User
# Default expiration for permission requests (in days)
DEFAULT_REQUEST_EXPIRY_DAYS = 7
# ============================================================================
# Avatar CRUD Operations
# ============================================================================
def create_avatar_for_user(
user_id: UUID,
db_session: Session,
name: str | None = None,
description: str | None = None,
) -> Avatar:
"""Create a new avatar for a user.
Args:
user_id: The ID of the user to create an avatar for
db_session: Database session
name: Optional display name for the avatar
description: Optional description for the avatar
Returns:
The created Avatar instance
"""
avatar = Avatar(
user_id=user_id,
name=name,
description=description,
is_enabled=True,
default_query_mode=AvatarQueryMode.OWNED_DOCUMENTS,
allow_accessible_mode=True,
show_query_in_request=True,
max_requests_per_day=100,
)
db_session.add(avatar)
db_session.flush()
return avatar
async def create_avatar_for_user_async(
user_id: UUID,
db_session: AsyncSession,
name: str | None = None,
description: str | None = None,
) -> Avatar:
"""Create a new avatar for a user (async version).
Args:
user_id: The ID of the user to create an avatar for
db_session: Async database session
name: Optional display name for the avatar
description: Optional description for the avatar
Returns:
The created Avatar instance
"""
avatar = Avatar(
user_id=user_id,
name=name,
description=description,
is_enabled=True,
default_query_mode=AvatarQueryMode.OWNED_DOCUMENTS,
allow_accessible_mode=True,
show_query_in_request=True,
max_requests_per_day=100,
)
db_session.add(avatar)
await db_session.flush()
return avatar
def get_avatar_by_id(avatar_id: int, db_session: Session) -> Avatar | None:
"""Get an avatar by its ID."""
return db_session.query(Avatar).filter(Avatar.id == avatar_id).first()
def get_avatar_by_user_id(user_id: UUID, db_session: Session) -> Avatar | None:
"""Get an avatar by its user ID."""
return db_session.query(Avatar).filter(Avatar.user_id == user_id).first()
def get_all_enabled_avatars(
db_session: Session,
exclude_user_id: UUID | None = None,
) -> list[Avatar]:
"""Get all enabled avatars, optionally excluding a specific user's avatar."""
query = db_session.query(Avatar).filter(Avatar.is_enabled == True) # noqa: E712
if exclude_user_id:
query = query.filter(Avatar.user_id != exclude_user_id)
return query.all()
def update_avatar(
avatar_id: int,
db_session: Session,
name: str | None = None,
description: str | None = None,
is_enabled: bool | None = None,
default_query_mode: AvatarQueryMode | None = None,
allow_accessible_mode: bool | None = None,
auto_approve_rules: dict | None = None,
show_query_in_request: bool | None = None,
max_requests_per_day: int | None = None,
) -> Avatar | None:
"""Update an avatar's settings.
Only non-None values will be updated.
"""
avatar = get_avatar_by_id(avatar_id, db_session)
if not avatar:
return None
if name is not None:
avatar.name = name
if description is not None:
avatar.description = description
if is_enabled is not None:
avatar.is_enabled = is_enabled
if default_query_mode is not None:
avatar.default_query_mode = default_query_mode
if allow_accessible_mode is not None:
avatar.allow_accessible_mode = allow_accessible_mode
if auto_approve_rules is not None:
avatar.auto_approve_rules = auto_approve_rules
if show_query_in_request is not None:
avatar.show_query_in_request = show_query_in_request
if max_requests_per_day is not None:
avatar.max_requests_per_day = max_requests_per_day
db_session.flush()
return avatar
def delete_avatar(avatar_id: int, db_session: Session) -> bool:
"""Delete an avatar by ID."""
avatar = get_avatar_by_id(avatar_id, db_session)
if not avatar:
return False
db_session.delete(avatar)
db_session.flush()
return True
# ============================================================================
# Avatar Permission Request Operations
# ============================================================================
def create_permission_request(
avatar_id: int,
requester_id: UUID,
query_text: str | None,
db_session: Session,
chat_session_id: UUID | None = None,
chat_message_id: int | None = None,
cached_answer: str | None = None,
cached_search_doc_ids: list[int] | None = None,
answer_quality_score: float | None = None,
expires_in_days: int = DEFAULT_REQUEST_EXPIRY_DAYS,
status: AvatarPermissionRequestStatus = AvatarPermissionRequestStatus.PENDING,
task_id: str | None = None,
) -> AvatarPermissionRequest:
"""Create a new permission request.
Args:
avatar_id: The avatar being queried
requester_id: The user making the request
query_text: The query text (may be hidden per privacy settings)
db_session: Database session
chat_session_id: Optional chat session for context
chat_message_id: Optional chat message for context
cached_answer: Pre-computed answer (for sync queries)
cached_search_doc_ids: Document IDs from the search
answer_quality_score: Quality score of the answer
expires_in_days: How long before the request expires
status: Initial status (PENDING for sync, PROCESSING for async)
task_id: Celery task ID for async processing
"""
request = AvatarPermissionRequest(
avatar_id=avatar_id,
requester_id=requester_id,
query_text=query_text,
chat_session_id=chat_session_id,
chat_message_id=chat_message_id,
cached_answer=cached_answer,
cached_search_doc_ids=cached_search_doc_ids,
answer_quality_score=answer_quality_score,
status=status,
task_id=task_id,
expires_at=datetime.utcnow() + timedelta(days=expires_in_days),
)
db_session.add(request)
db_session.flush()
return request
def update_permission_request_task_id(
request_id: int,
task_id: str,
db_session: Session,
) -> AvatarPermissionRequest | None:
"""Update the task_id for a permission request after queuing."""
request = get_permission_request_by_id(request_id, db_session)
if not request:
return None
request.task_id = task_id
db_session.flush()
return request
def get_permission_request_by_id(
request_id: int, db_session: Session
) -> AvatarPermissionRequest | None:
"""Get a permission request by ID."""
return (
db_session.query(AvatarPermissionRequest)
.filter(AvatarPermissionRequest.id == request_id)
.first()
)
def get_pending_requests_for_avatar_owner(
user_id: UUID, db_session: Session
) -> list[AvatarPermissionRequest]:
"""Get all pending permission requests for a user's avatar."""
return (
db_session.query(AvatarPermissionRequest)
.join(Avatar, AvatarPermissionRequest.avatar_id == Avatar.id)
.filter(
Avatar.user_id == user_id,
AvatarPermissionRequest.status == AvatarPermissionRequestStatus.PENDING,
AvatarPermissionRequest.expires_at > datetime.utcnow(),
)
.order_by(AvatarPermissionRequest.created_at.desc())
.all()
)
def get_permission_requests_by_requester(
requester_id: UUID,
db_session: Session,
status: AvatarPermissionRequestStatus | None = None,
) -> list[AvatarPermissionRequest]:
"""Get all permission requests made by a user."""
query = db_session.query(AvatarPermissionRequest).filter(
AvatarPermissionRequest.requester_id == requester_id
)
if status:
query = query.filter(AvatarPermissionRequest.status == status)
return query.order_by(AvatarPermissionRequest.created_at.desc()).all()
def get_permission_requests_by_chat_session(
chat_session_id: UUID,
requester_id: UUID,
db_session: Session,
) -> list[AvatarPermissionRequest]:
"""Get all permission requests for a specific chat session.
Only returns requests made by the specified requester for security.
Returns all statuses so the UI can show pending, approved, and denied requests.
"""
return (
db_session.query(AvatarPermissionRequest)
.filter(
AvatarPermissionRequest.chat_session_id == chat_session_id,
AvatarPermissionRequest.requester_id == requester_id,
)
.order_by(AvatarPermissionRequest.created_at.desc())
.all()
)
def approve_permission_request(
request_id: int, db_session: Session
) -> AvatarPermissionRequest | None:
"""Approve a permission request."""
request = get_permission_request_by_id(request_id, db_session)
if not request or request.status != AvatarPermissionRequestStatus.PENDING:
return None
request.status = AvatarPermissionRequestStatus.APPROVED
request.resolved_at = datetime.utcnow()
db_session.flush()
return request
def deny_permission_request(
request_id: int,
db_session: Session,
denial_reason: str | None = None,
) -> AvatarPermissionRequest | None:
"""Deny a permission request."""
request = get_permission_request_by_id(request_id, db_session)
if not request or request.status != AvatarPermissionRequestStatus.PENDING:
return None
request.status = AvatarPermissionRequestStatus.DENIED
request.denial_reason = denial_reason
request.resolved_at = datetime.utcnow()
db_session.flush()
return request
def expire_old_permission_requests(db_session: Session) -> int:
"""Mark all expired permission requests as expired.
Returns the number of requests that were expired.
"""
expired_count = (
db_session.query(AvatarPermissionRequest)
.filter(
AvatarPermissionRequest.status == AvatarPermissionRequestStatus.PENDING,
AvatarPermissionRequest.expires_at <= datetime.utcnow(),
)
.update(
{
AvatarPermissionRequest.status: AvatarPermissionRequestStatus.EXPIRED,
AvatarPermissionRequest.resolved_at: datetime.utcnow(),
}
)
)
db_session.flush()
return expired_count
# ============================================================================
# Avatar Query Operations (Rate Limiting & Analytics)
# ============================================================================
def log_avatar_query(
avatar_id: int,
requester_id: UUID,
query_mode: AvatarQueryMode,
query_text: str,
db_session: Session,
) -> AvatarQuery:
"""Log an avatar query for rate limiting and analytics."""
query = AvatarQuery(
avatar_id=avatar_id,
requester_id=requester_id,
query_mode=query_mode,
query_text=query_text,
)
db_session.add(query)
db_session.flush()
return query
def get_avatar_query_count_today(
avatar_id: int,
requester_id: UUID,
db_session: Session,
) -> int:
"""Get the number of queries made to an avatar by a user today."""
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
return (
db_session.query(AvatarQuery)
.filter(
AvatarQuery.avatar_id == avatar_id,
AvatarQuery.requester_id == requester_id,
AvatarQuery.created_at >= today_start,
)
.count()
)
def check_rate_limit(
avatar_id: int,
requester_id: UUID,
db_session: Session,
) -> bool:
"""Check if a requester has exceeded the rate limit for an avatar.
Returns True if the request is allowed, False if rate limited.
"""
avatar = get_avatar_by_id(avatar_id, db_session)
if not avatar or not avatar.max_requests_per_day:
return True
query_count = get_avatar_query_count_today(avatar_id, requester_id, db_session)
return query_count < avatar.max_requests_per_day
# ============================================================================
# Auto-Approval Logic
# ============================================================================
def should_auto_approve(
avatar: Avatar,
requester: User,
) -> bool:
"""Check if a request should be auto-approved based on avatar's rules.
Auto-approve rules format:
{
"user_ids": ["uuid1", "uuid2"],
"group_ids": ["group1", "group2"],
"all_users": false
}
"""
if not avatar.auto_approve_rules:
return False
rules = avatar.auto_approve_rules
# Check if all users are auto-approved
if rules.get("all_users", False):
return True
# Check if requester is in the user whitelist
user_ids = rules.get("user_ids", [])
if str(requester.id) in user_ids:
return True
# Check if requester is in any of the whitelisted groups
# Note: This would need integration with the UserGroup system
# group_ids = rules.get("group_ids", [])
# if group_ids:
# # TODO: Check if user is member of any whitelisted group
# pass
return False

View File

@@ -194,3 +194,21 @@ class SwitchoverType(str, PyEnum):
REINDEX = "reindex"
ACTIVE_ONLY = "active_only"
INSTANT = "instant"
class AvatarQueryMode(str, PyEnum):
"""Mode for querying an avatar's knowledge."""
OWNED_DOCUMENTS = "owned_documents" # Query only docs where user is primary_owner
ACCESSIBLE_DOCUMENTS = "accessible_documents" # Query all docs user can access
class AvatarPermissionRequestStatus(str, PyEnum):
"""Status of an avatar permission request."""
PENDING = "pending" # Awaiting owner approval (accessible mode)
PROCESSING = "processing" # Query is being executed in background
APPROVED = "approved"
DENIED = "denied"
EXPIRED = "expired"
NO_ANSWER = "no_answer" # Query ran but found nothing useful

View File

@@ -54,6 +54,8 @@ from onyx.configs.constants import FileOrigin
from onyx.configs.constants import MessageType
from onyx.db.enums import (
AccessType,
AvatarPermissionRequestStatus,
AvatarQueryMode,
EmbeddingPrecision,
IndexingMode,
SyncType,
@@ -256,6 +258,13 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
back_populates="user",
cascade="all, delete-orphan",
)
# User's queryable avatar (1:1 relationship)
avatar: Mapped["Avatar | None"] = relationship(
"Avatar",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
)
@validates("email")
def validate_email(self, key: str, value: str) -> str:
@@ -3911,3 +3920,190 @@ class ExternalGroupPermissionSyncAttempt(Base):
def is_finished(self) -> bool:
return self.status.is_terminal()
"""
Avatar Models
Avatars are queryable mirrors of individual users within Onyx.
"""
class Avatar(Base):
"""User's queryable knowledge avatar - mirrors their document ownership/access."""
__tablename__ = "avatar"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[UUID] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), nullable=False, unique=True
)
# Display settings
name: Mapped[str | None] = mapped_column(String, nullable=True)
description: Mapped[str | None] = mapped_column(String, nullable=True)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Query mode settings
default_query_mode: Mapped[AvatarQueryMode] = mapped_column(
Enum(AvatarQueryMode, native_enum=False),
default=AvatarQueryMode.OWNED_DOCUMENTS,
nullable=False,
)
allow_accessible_mode: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
# Auto-approval rules: {"user_ids": [...], "group_ids": [...], "all_users": false}
auto_approve_rules: Mapped[dict | None] = mapped_column(
postgresql.JSONB(), nullable=True
)
# Privacy settings
show_query_in_request: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
# Rate limiting
max_requests_per_day: Mapped[int | None] = mapped_column(
Integer, nullable=True, default=100
)
# Timestamps
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="avatar")
permission_requests: Mapped[list["AvatarPermissionRequest"]] = relationship(
"AvatarPermissionRequest",
back_populates="avatar",
cascade="all, delete-orphan",
)
queries: Mapped[list["AvatarQuery"]] = relationship(
"AvatarQuery",
back_populates="avatar",
cascade="all, delete-orphan",
)
class AvatarPermissionRequest(Base):
"""Tracks permission requests for accessible-mode avatar queries."""
__tablename__ = "avatar_permission_request"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# The avatar being queried
avatar_id: Mapped[int] = mapped_column(
ForeignKey("avatar.id", ondelete="CASCADE"), nullable=False, index=True
)
# Who is requesting
requester_id: Mapped[UUID] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True
)
# The query context
query_text: Mapped[str | None] = mapped_column(
Text, nullable=True
) # May be hidden per privacy settings
chat_session_id: Mapped[UUID | None] = mapped_column(
ForeignKey("chat_session.id", ondelete="SET NULL"), nullable=True
)
chat_message_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_message.id", ondelete="SET NULL"), nullable=True
)
# Cached answer (stored until approval/denial)
cached_answer: Mapped[str | None] = mapped_column(Text, nullable=True)
cached_search_doc_ids: Mapped[list[int] | None] = mapped_column(
postgresql.JSONB(), nullable=True
)
answer_quality_score: Mapped[float | None] = mapped_column(Float, nullable=True)
# Status
status: Mapped[AvatarPermissionRequestStatus] = mapped_column(
Enum(AvatarPermissionRequestStatus, native_enum=False),
default=AvatarPermissionRequestStatus.PENDING,
nullable=False,
index=True,
)
# Background task tracking (for PROCESSING status)
task_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
# Response from avatar owner
denial_reason: Mapped[str | None] = mapped_column(String, nullable=True)
# Timestamps
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
expires_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
resolved_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Relationships
avatar: Mapped["Avatar"] = relationship(
"Avatar", back_populates="permission_requests"
)
requester: Mapped["User"] = relationship("User", foreign_keys=[requester_id])
chat_session: Mapped["ChatSession | None"] = relationship("ChatSession")
__table_args__ = (
Index(
"ix_avatar_permission_request_avatar_status",
"avatar_id",
"status",
),
Index(
"ix_avatar_permission_request_requester_created",
"requester_id",
"created_at",
),
)
class AvatarQuery(Base):
"""Tracks avatar queries for rate limiting and analytics."""
__tablename__ = "avatar_query"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
avatar_id: Mapped[int] = mapped_column(
ForeignKey("avatar.id", ondelete="CASCADE"), nullable=False, index=True
)
requester_id: Mapped[UUID] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True
)
query_mode: Mapped[AvatarQueryMode] = mapped_column(
Enum(AvatarQueryMode, native_enum=False), nullable=False
)
query_text: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
avatar: Mapped["Avatar"] = relationship("Avatar", back_populates="queries")
requester: Mapped["User"] = relationship("User", foreign_keys=[requester_id])
# Index for rate limiting queries
__table_args__ = (
Index(
"ix_avatar_query_rate_limit",
"avatar_id",
"requester_id",
"created_at",
),
)

View File

@@ -12,6 +12,7 @@ from onyx.document_index.vespa_constants import DOCUMENT_ID
from onyx.document_index.vespa_constants import DOCUMENT_SETS
from onyx.document_index.vespa_constants import HIDDEN
from onyx.document_index.vespa_constants import METADATA_LIST
from onyx.document_index.vespa_constants import PRIMARY_OWNERS
from onyx.document_index.vespa_constants import SOURCE_TYPE
from onyx.document_index.vespa_constants import TENANT_ID
from onyx.document_index.vespa_constants import USER_PROJECT
@@ -165,6 +166,10 @@ def build_vespa_filters(
ACCESS_CONTROL_LIST, filters.access_control_list
)
# Primary owner filter (for avatar queries)
if filters.primary_owner_emails:
filter_str += _build_or_filters(PRIMARY_OWNERS, filters.primary_owner_emails)
# Source type filters
source_strs = (
[s.value for s in filters.source_type] if filters.source_type else None

View File

@@ -461,10 +461,10 @@ def llm_max_input_tokens(
if "max_tokens" in model_obj:
return model_obj["max_tokens"]
logger.warning(
f"No max tokens found for '{model_name}'. "
f"Falling back to {GEN_AI_MODEL_FALLBACK_MAX_TOKENS} tokens."
)
# logger.warning(
# f"No max tokens found for '{model_name}'. "
# f"Falling back to {GEN_AI_MODEL_FALLBACK_MAX_TOKENS} tokens."
# )
return GEN_AI_MODEL_FALLBACK_MAX_TOKENS
@@ -672,7 +672,7 @@ def model_is_reasoning_model(model_name: str, model_provider: str) -> bool:
# Fallback: try using litellm.supports_reasoning() for newer models
try:
logger.debug("Falling back to `litellm.supports_reasoning`")
# logger.debug("Falling back to `litellm.supports_reasoning`")
full_model_name = (
f"{model_provider}/{model_name}"
if model_provider not in model_name

View File

@@ -64,6 +64,11 @@ from onyx.server.documents.connector import router as connector_router
from onyx.server.documents.credential import router as credential_router
from onyx.server.documents.document import router as document_router
from onyx.server.documents.standard_oauth import router as standard_oauth_router
from onyx.server.features.avatar.api import router as avatar_router
from onyx.server.features.avatar.permission_api import (
router as avatar_permission_router,
)
from onyx.server.features.avatar.query_api import router as avatar_query_router
from onyx.server.features.default_assistant.api import (
router as default_assistant_router,
)
@@ -389,6 +394,9 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
include_router_with_global_prefix_prepended(application, admin_agents_router)
include_router_with_global_prefix_prepended(application, default_assistant_router)
include_router_with_global_prefix_prepended(application, notification_router)
include_router_with_global_prefix_prepended(application, avatar_router)
include_router_with_global_prefix_prepended(application, avatar_permission_router)
include_router_with_global_prefix_prepended(application, avatar_query_router)
include_router_with_global_prefix_prepended(application, tool_router)
include_router_with_global_prefix_prepended(application, admin_tool_router)
include_router_with_global_prefix_prepended(application, oauth_config_router)

View File

@@ -0,0 +1,104 @@
"""
Avatar management API endpoints.
"""
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.db.avatar import create_avatar_for_user
from onyx.db.avatar import get_all_enabled_avatars
from onyx.db.avatar import get_avatar_by_id
from onyx.db.avatar import get_avatar_by_user_id
from onyx.db.avatar import update_avatar
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.server.features.avatar.models import AvatarListItem
from onyx.server.features.avatar.models import AvatarSnapshot
from onyx.server.features.avatar.models import AvatarUpdateRequest
from onyx.utils.logger import setup_logger
logger = setup_logger()
router = APIRouter(prefix="/avatar")
@router.get("/me")
def get_my_avatar(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> AvatarSnapshot:
"""Get the current user's avatar."""
avatar = get_avatar_by_user_id(user.id, db_session)
if not avatar:
# Create avatar if it doesn't exist (for users created before the feature)
avatar = create_avatar_for_user(
user_id=user.id,
db_session=db_session,
name=None,
description=None,
)
db_session.commit()
return AvatarSnapshot.from_model(avatar)
@router.patch("/me")
def update_my_avatar(
update_request: AvatarUpdateRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> AvatarSnapshot:
"""Update the current user's avatar settings."""
avatar = get_avatar_by_user_id(user.id, db_session)
if not avatar:
raise HTTPException(status_code=404, detail="Avatar not found")
updated_avatar = update_avatar(
avatar_id=avatar.id,
db_session=db_session,
name=update_request.name,
description=update_request.description,
is_enabled=update_request.is_enabled,
default_query_mode=update_request.default_query_mode,
allow_accessible_mode=update_request.allow_accessible_mode,
auto_approve_rules=update_request.auto_approve_rules,
show_query_in_request=update_request.show_query_in_request,
max_requests_per_day=update_request.max_requests_per_day,
)
if not updated_avatar:
raise HTTPException(status_code=500, detail="Failed to update avatar")
db_session.commit()
return AvatarSnapshot.from_model(updated_avatar)
@router.get("/list")
def list_queryable_avatars(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> list[AvatarListItem]:
"""List all enabled avatars that can be queried, excluding the current user's avatar."""
avatars = get_all_enabled_avatars(db_session, exclude_user_id=user.id)
return [AvatarListItem.from_model(avatar) for avatar in avatars]
@router.get("/{avatar_id}")
def get_avatar(
avatar_id: int,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> AvatarSnapshot:
"""Get a specific avatar by ID."""
avatar = get_avatar_by_id(avatar_id, db_session)
if not avatar:
raise HTTPException(status_code=404, detail="Avatar not found")
if not avatar.is_enabled and avatar.user_id != user.id:
raise HTTPException(status_code=404, detail="Avatar not found")
return AvatarSnapshot.from_model(avatar)

View File

@@ -0,0 +1,217 @@
"""
Pydantic models for Avatar API endpoints.
"""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
from pydantic import Field
from onyx.db.enums import AvatarPermissionRequestStatus
from onyx.db.enums import AvatarQueryMode
from onyx.db.models import Avatar
from onyx.db.models import AvatarPermissionRequest
# ============================================================================
# Avatar Models
# ============================================================================
class AvatarSnapshot(BaseModel):
"""Snapshot of an avatar for API responses."""
id: int
user_id: UUID
user_email: str
name: str | None
description: str | None
is_enabled: bool
default_query_mode: AvatarQueryMode
allow_accessible_mode: bool
show_query_in_request: bool
max_requests_per_day: int | None
created_at: datetime
@classmethod
def from_model(cls, avatar: Avatar) -> "AvatarSnapshot":
return AvatarSnapshot(
id=avatar.id,
user_id=avatar.user_id,
user_email=avatar.user.email,
name=avatar.name,
description=avatar.description,
is_enabled=avatar.is_enabled,
default_query_mode=avatar.default_query_mode,
allow_accessible_mode=avatar.allow_accessible_mode,
show_query_in_request=avatar.show_query_in_request,
max_requests_per_day=avatar.max_requests_per_day,
created_at=avatar.created_at,
)
class AvatarUpdateRequest(BaseModel):
"""Request to update avatar settings."""
name: str | None = None
description: str | None = None
is_enabled: bool | None = None
default_query_mode: AvatarQueryMode | None = None
allow_accessible_mode: bool | None = None
show_query_in_request: bool | None = None
max_requests_per_day: int | None = None
auto_approve_rules: dict | None = None
class AvatarListItem(BaseModel):
"""Minimal avatar info for listing queryable avatars."""
id: int
user_id: UUID
user_email: str
name: str | None
description: str | None
default_query_mode: AvatarQueryMode
allow_accessible_mode: bool
@classmethod
def from_model(cls, avatar: Avatar) -> "AvatarListItem":
return AvatarListItem(
id=avatar.id,
user_id=avatar.user_id,
user_email=avatar.user.email,
name=avatar.name,
description=avatar.description,
default_query_mode=avatar.default_query_mode,
allow_accessible_mode=avatar.allow_accessible_mode,
)
# ============================================================================
# Avatar Query Models
# ============================================================================
class AvatarQueryRequest(BaseModel):
"""Request to query an avatar."""
query: str
query_mode: AvatarQueryMode = AvatarQueryMode.OWNED_DOCUMENTS
chat_session_id: UUID | None = None
class AvatarQueryResponse(BaseModel):
"""Response from an avatar query."""
status: str
# Possible status values:
# - "success": Query completed, answer available
# - "processing": Query is running in background (All mode)
# - "pending_permission": Query done, awaiting owner approval (All mode)
# - "no_results": No relevant documents found
# - "rate_limited": Rate limit exceeded
# - "disabled": Avatar is disabled
# - "error": General error
answer: str | None = None
permission_request_id: int | None = None
source_document_ids: list[str] | None = None
message: str | None = None
class BroadcastQueryRequest(BaseModel):
"""Request to query multiple avatars."""
avatar_ids: list[int]
query: str
query_mode: AvatarQueryMode = AvatarQueryMode.OWNED_DOCUMENTS
class BroadcastQueryResponse(BaseModel):
"""Response from a broadcast query to multiple avatars."""
results: dict[int, AvatarQueryResponse] # avatar_id -> response
# ============================================================================
# Permission Request Models
# ============================================================================
class PermissionRequestSnapshot(BaseModel):
"""Snapshot of a permission request for API responses."""
id: int
avatar_id: int
avatar_user_email: str
requester_id: UUID
requester_email: str
query_text: str | None # May be hidden based on avatar settings
status: AvatarPermissionRequestStatus
task_id: str | None = None # Celery task ID for PROCESSING status
denial_reason: str | None
created_at: datetime
expires_at: datetime
resolved_at: datetime | None
# Only included for approved requests when the user is the requester
cached_answer: str | None = None
cached_search_doc_ids: list[int] | None = None
@classmethod
def from_model(
cls,
request: AvatarPermissionRequest,
show_query: bool = True,
include_answer: bool = False,
) -> "PermissionRequestSnapshot":
# Only include the cached answer for approved requests when requested
cached_answer = None
cached_search_doc_ids = None
if include_answer and request.status == AvatarPermissionRequestStatus.APPROVED:
cached_answer = request.cached_answer
cached_search_doc_ids = request.cached_search_doc_ids
return PermissionRequestSnapshot(
id=request.id,
avatar_id=request.avatar_id,
avatar_user_email=request.avatar.user.email,
requester_id=request.requester_id,
requester_email=request.requester.email,
query_text=request.query_text if show_query else None,
status=request.status,
task_id=request.task_id,
denial_reason=request.denial_reason,
created_at=request.created_at,
expires_at=request.expires_at,
resolved_at=request.resolved_at,
cached_answer=cached_answer,
cached_search_doc_ids=cached_search_doc_ids,
)
class PermissionRequestDenyRequest(BaseModel):
"""Request to deny a permission request."""
denial_reason: str | None = None
class PermissionRequestApproveResponse(BaseModel):
"""Response when a permission request is approved, including the cached answer."""
request_id: int
status: AvatarPermissionRequestStatus
answer: str | None
source_document_ids: list[int] | None
# ============================================================================
# Auto-Approve Rules Models
# ============================================================================
class AutoApproveRules(BaseModel):
"""Rules for auto-approving permission requests."""
user_ids: list[str] = Field(default_factory=list)
group_ids: list[int] = Field(default_factory=list)
all_users: bool = False

View File

@@ -0,0 +1,216 @@
"""
Avatar permission request API endpoints.
"""
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.configs.constants import NotificationType
from onyx.db.avatar import approve_permission_request
from onyx.db.avatar import deny_permission_request
from onyx.db.avatar import get_avatar_by_user_id
from onyx.db.avatar import get_pending_requests_for_avatar_owner
from onyx.db.avatar import get_permission_request_by_id
from onyx.db.avatar import get_permission_requests_by_chat_session
from onyx.db.avatar import get_permission_requests_by_requester
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import AvatarPermissionRequestStatus
from onyx.db.models import User
from onyx.db.notification import create_notification
from onyx.server.features.avatar.models import PermissionRequestApproveResponse
from onyx.server.features.avatar.models import PermissionRequestDenyRequest
from onyx.server.features.avatar.models import PermissionRequestSnapshot
from onyx.utils.logger import setup_logger
logger = setup_logger()
router = APIRouter(prefix="/avatar/permissions")
@router.get("/incoming")
def get_incoming_permission_requests(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> list[PermissionRequestSnapshot]:
"""Get all pending permission requests for the current user's avatar."""
requests = get_pending_requests_for_avatar_owner(user.id, db_session)
# Get the user's avatar to check show_query_in_request setting
avatar = get_avatar_by_user_id(user.id, db_session)
show_query = avatar.show_query_in_request if avatar else True
return [
PermissionRequestSnapshot.from_model(req, show_query=show_query)
for req in requests
]
@router.get("/outgoing")
def get_outgoing_permission_requests(
status: AvatarPermissionRequestStatus | None = None,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> list[PermissionRequestSnapshot]:
"""Get all permission requests made by the current user."""
requests = get_permission_requests_by_requester(user.id, db_session, status=status)
# Include the answer for the requester's own requests
return [
PermissionRequestSnapshot.from_model(req, show_query=True, include_answer=True)
for req in requests
]
@router.get("/chat-session/{chat_session_id}")
def get_permission_requests_for_chat_session(
chat_session_id: str,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> list[PermissionRequestSnapshot]:
"""Get all permission requests for a specific chat session.
Returns requests with all statuses so the UI can show:
- Pending requests (awaiting approval)
- Approved requests (with answers)
- Denied requests (with denial reason)
"""
from uuid import UUID
try:
session_uuid = UUID(chat_session_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid chat session ID")
requests = get_permission_requests_by_chat_session(
session_uuid, user.id, db_session
)
# Include the answer for the requester's own requests
return [
PermissionRequestSnapshot.from_model(req, show_query=True, include_answer=True)
for req in requests
]
@router.get("/{request_id}")
def get_permission_request(
request_id: int,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> PermissionRequestSnapshot:
"""Get a specific permission request."""
request = get_permission_request_by_id(request_id, db_session)
if not request:
raise HTTPException(status_code=404, detail="Permission request not found")
# User can view if they are the requester or the avatar owner
is_requester = request.requester_id == user.id
is_avatar_owner = request.avatar.user_id == user.id
if not is_requester and not is_avatar_owner:
raise HTTPException(status_code=403, detail="Access denied")
# Show query to requester, but respect avatar owner's preference
show_query = is_requester or request.avatar.show_query_in_request
# Include answer only for the requester
include_answer = is_requester
return PermissionRequestSnapshot.from_model(
request, show_query=show_query, include_answer=include_answer
)
@router.post("/{request_id}/approve")
def approve_request(
request_id: int,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> PermissionRequestApproveResponse:
"""Approve a permission request."""
request = get_permission_request_by_id(request_id, db_session)
if not request:
raise HTTPException(status_code=404, detail="Permission request not found")
# Only the avatar owner can approve
if request.avatar.user_id != user.id:
raise HTTPException(
status_code=403, detail="Only the avatar owner can approve requests"
)
if request.status != AvatarPermissionRequestStatus.PENDING:
raise HTTPException(
status_code=400,
detail=f"Request is already {request.status.value}",
)
approved_request = approve_permission_request(request_id, db_session)
if not approved_request:
raise HTTPException(status_code=500, detail="Failed to approve request")
# Notify the requester
create_notification(
user_id=request.requester_id,
notif_type=NotificationType.AVATAR_REQUEST_APPROVED,
db_session=db_session,
additional_data={"request_id": request_id, "avatar_id": request.avatar_id},
)
db_session.commit()
return PermissionRequestApproveResponse(
request_id=request_id,
status=AvatarPermissionRequestStatus.APPROVED,
answer=approved_request.cached_answer,
source_document_ids=approved_request.cached_search_doc_ids,
)
@router.post("/{request_id}/deny")
def deny_request(
request_id: int,
deny_request_body: PermissionRequestDenyRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> PermissionRequestSnapshot:
"""Deny a permission request."""
request = get_permission_request_by_id(request_id, db_session)
if not request:
raise HTTPException(status_code=404, detail="Permission request not found")
# Only the avatar owner can deny
if request.avatar.user_id != user.id:
raise HTTPException(
status_code=403, detail="Only the avatar owner can deny requests"
)
if request.status != AvatarPermissionRequestStatus.PENDING:
raise HTTPException(
status_code=400,
detail=f"Request is already {request.status.value}",
)
denied_request = deny_permission_request(
request_id,
db_session,
denial_reason=deny_request_body.denial_reason,
)
if not denied_request:
raise HTTPException(status_code=500, detail="Failed to deny request")
# Notify the requester
create_notification(
user_id=request.requester_id,
notif_type=NotificationType.AVATAR_REQUEST_DENIED,
db_session=db_session,
additional_data={
"request_id": request_id,
"avatar_id": request.avatar_id,
"denial_reason": deny_request_body.denial_reason,
},
)
db_session.commit()
return PermissionRequestSnapshot.from_model(denied_request, show_query=True)

View File

@@ -0,0 +1,96 @@
"""
Avatar query API endpoints.
"""
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.db.avatar import get_avatar_by_id
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.server.features.avatar.models import AvatarQueryRequest
from onyx.server.features.avatar.models import AvatarQueryResponse
from onyx.server.features.avatar.models import BroadcastQueryRequest
from onyx.server.features.avatar.models import BroadcastQueryResponse
from onyx.server.features.avatar.query_service import execute_avatar_query
from onyx.server.features.avatar.query_service import execute_broadcast_query
from onyx.utils.logger import setup_logger
logger = setup_logger()
router = APIRouter(prefix="/avatar/query")
@router.post("/single/{avatar_id}")
def query_single_avatar(
avatar_id: int,
request: AvatarQueryRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> AvatarQueryResponse:
"""Query a single avatar."""
avatar = get_avatar_by_id(avatar_id, db_session)
if not avatar:
raise HTTPException(status_code=404, detail="Avatar not found")
# Users cannot query their own avatar
if avatar.user_id == user.id:
raise HTTPException(
status_code=400,
detail="You cannot query your own avatar",
)
response = execute_avatar_query(
avatar_id=avatar_id,
query=request.query,
query_mode=request.query_mode,
requester=user,
db_session=db_session,
chat_session_id=request.chat_session_id,
)
return response
@router.post("/broadcast")
def query_multiple_avatars(
request: BroadcastQueryRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> BroadcastQueryResponse:
"""Query multiple avatars at once."""
if not request.avatar_ids:
raise HTTPException(status_code=400, detail="No avatar IDs provided")
if len(request.avatar_ids) > 10:
raise HTTPException(
status_code=400,
detail="Cannot query more than 10 avatars at once",
)
# Filter out the user's own avatar
filtered_avatar_ids = []
for avatar_id in request.avatar_ids:
avatar = get_avatar_by_id(avatar_id, db_session)
if avatar and avatar.user_id != user.id:
filtered_avatar_ids.append(avatar_id)
if not filtered_avatar_ids:
raise HTTPException(
status_code=400,
detail="No valid avatars to query (you cannot query your own avatar)",
)
results = execute_broadcast_query(
avatar_ids=filtered_avatar_ids,
query=request.query,
query_mode=request.query_mode,
requester=user,
db_session=db_session,
)
return BroadcastQueryResponse(results=results)

View File

@@ -0,0 +1,415 @@
"""
Avatar query service for executing searches against avatars.
This module handles the core logic for querying avatars, including:
- Owned documents mode (instant, no permission required)
- Accessible documents mode (requires permission if answer found)
- Permission request creation and caching
"""
from uuid import UUID
from sqlalchemy.orm import Session
from onyx.context.search.models import IndexFilters
from onyx.context.search.models import InferenceChunk
from onyx.context.search.models import QueryExpansionType
from onyx.context.search.preprocessing.access_filters import (
build_access_filters_for_user,
)
from onyx.context.search.utils import get_query_embedding
from onyx.db.avatar import check_rate_limit
from onyx.db.avatar import create_permission_request
from onyx.db.avatar import get_avatar_by_id
from onyx.db.avatar import log_avatar_query
from onyx.db.avatar import should_auto_approve
from onyx.db.enums import AvatarPermissionRequestStatus
from onyx.db.enums import AvatarQueryMode
from onyx.db.models import Avatar
from onyx.db.models import User
from onyx.document_index.factory import get_current_primary_default_document_index
from onyx.llm.factory import get_default_llms
from onyx.llm.factory import get_main_llm_from_tuple
from onyx.llm.message_types import SystemMessage
from onyx.llm.message_types import UserMessageWithText
from onyx.server.features.avatar.models import AvatarQueryResponse
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
# Prompt for generating answers from avatar query results
AVATAR_ANSWER_SYSTEM_PROMPT = """You are a helpful assistant answering questions based on documents \
owned by or accessible to a specific user (the "avatar").
Your task is to synthesize information from the provided document excerpts and \
generate a clear, accurate answer to the user's question.
Guidelines:
- Base your answer ONLY on the provided document excerpts
- Be concise but thorough
- If the documents don't contain enough information to fully answer the question, \
acknowledge what information is available and what is missing
- Use a professional, helpful tone
- When referencing specific information, indicate which document it came from using [1], [2], etc."""
AVATAR_ANSWER_USER_PROMPT_TEMPLATE = """Based on the following document excerpts from {avatar_name}'s \
documents, please answer this question:
Question: {query}
Document Excerpts:
{context}
Please provide a clear, helpful answer based on the information above."""
# Minimum score threshold for considering results "good enough"
MIN_RESULT_SCORE = 0.3
# Minimum number of chunks needed to consider a query successful
MIN_CHUNKS_FOR_ANSWER = 1
def _build_owned_documents_filters(
avatar: Avatar,
user: User,
db_session: Session,
) -> IndexFilters:
"""Build filters for querying documents owned by the avatar's user."""
return IndexFilters(
source_type=None,
document_set=None,
time_cutoff=None,
tags=None,
# should still only give back docs the query user has access to
access_control_list=list(build_access_filters_for_user(user, db_session)),
primary_owner_emails=[avatar.user.email],
tenant_id=get_current_tenant_id() if MULTI_TENANT else None,
)
def _build_accessible_documents_filters(
avatar: Avatar,
db_session: Session,
) -> IndexFilters:
"""Build filters for querying all documents accessible to the avatar's user."""
# Get the ACL for the avatar's user (not the requester)
user_acl = build_access_filters_for_user(avatar.user, db_session)
return IndexFilters(
source_type=None,
document_set=None,
time_cutoff=None,
tags=None,
access_control_list=list(user_acl),
tenant_id=get_current_tenant_id() if MULTI_TENANT else None,
)
def _execute_search(
query: str,
filters: IndexFilters,
db_session: Session,
num_results: int = 10,
) -> list[InferenceChunk]:
"""Execute a hybrid search with the given filters.
Uses the document index's hybrid_retrieval which combines
semantic (embedding) and keyword search.
"""
try:
# Get query embedding
query_embedding = get_query_embedding(query, db_session)
# Get document index
document_index = get_current_primary_default_document_index(db_session)
# Execute hybrid search
chunks = document_index.hybrid_retrieval(
query=query,
query_embedding=query_embedding,
final_keywords=None,
filters=filters,
hybrid_alpha=0.5, # Balance between semantic and keyword
time_decay_multiplier=1.0,
num_to_retrieve=num_results,
ranking_profile_type=QueryExpansionType.SEMANTIC,
)
return chunks[:num_results]
except Exception as e:
logger.error(f"Search failed: {e}")
return []
def _generate_answer(
query: str,
chunks: list[InferenceChunk],
avatar: Avatar,
) -> str | None:
"""Generate an answer from the retrieved chunks using the LLM.
Uses the default LLM to generate a contextual answer based on the
retrieved document chunks, similar to the normal chat flow.
"""
if not chunks:
return None
# Build context from chunks
context_parts = []
for i, chunk in enumerate(chunks[:5], 1):
source = chunk.semantic_identifier or chunk.document_id
context_parts.append(f"[{i}] Source: {source}\n{chunk.content}")
context = "\n\n---\n\n".join(context_parts)
# Get avatar display name
avatar_name = avatar.name or avatar.user.email
# Build the user prompt
user_prompt = AVATAR_ANSWER_USER_PROMPT_TEMPLATE.format(
avatar_name=avatar_name,
query=query,
context=context,
)
try:
# Get the default LLM (use the fast model for avatar queries)
llms = get_default_llms()
llm = get_main_llm_from_tuple(llms)
# Generate the answer with properly typed messages
system_msg: SystemMessage = {
"role": "system",
"content": AVATAR_ANSWER_SYSTEM_PROMPT,
}
user_msg: UserMessageWithText = {
"role": "user",
"content": user_prompt,
}
response = llm.invoke([system_msg, user_msg])
# Access the content from the ModelResponse structure
if response and response.choice and response.choice.message:
content = response.choice.message.content
if content:
return content
return None
except Exception as e:
logger.error(f"Failed to generate LLM answer for avatar query: {e}")
# Fall back to simple summary if LLM fails
summary_parts = []
for i, chunk in enumerate(chunks[:5], 1):
source = chunk.semantic_identifier or chunk.document_id
preview = (
chunk.content[:200] + "..."
if len(chunk.content) > 200
else chunk.content
)
summary_parts.append(f"[{i}] {source}: {preview}")
return "\n\n".join(summary_parts)
def _has_good_results(chunks: list[InferenceChunk]) -> bool:
"""Check if the search results are good enough to proceed."""
if len(chunks) < MIN_CHUNKS_FOR_ANSWER:
return False
# Check if at least one chunk has a good score
for chunk in chunks:
if chunk.score and chunk.score >= MIN_RESULT_SCORE:
return True
return len(chunks) >= MIN_CHUNKS_FOR_ANSWER
def execute_avatar_query(
avatar_id: int,
query: str,
query_mode: AvatarQueryMode,
requester: User,
db_session: Session,
chat_session_id: UUID | None = None,
chat_message_id: int | None = None,
) -> AvatarQueryResponse:
"""Execute a query against an avatar.
Args:
avatar_id: ID of the avatar to query
query: The search query text
query_mode: Whether to search owned documents or all accessible documents
requester: The user making the request
db_session: Database session
chat_session_id: Optional chat session ID for context
chat_message_id: Optional chat message ID for context
Returns:
AvatarQueryResponse with status and results
"""
# Get the avatar
avatar = get_avatar_by_id(avatar_id, db_session)
if not avatar:
return AvatarQueryResponse(
status="error",
message="Avatar not found",
)
# Check if avatar is enabled
if not avatar.is_enabled:
return AvatarQueryResponse(
status="disabled",
message="This avatar is currently disabled",
)
# Check if the requested mode is allowed
if (
query_mode == AvatarQueryMode.ACCESSIBLE_DOCUMENTS
and not avatar.allow_accessible_mode
):
return AvatarQueryResponse(
status="error",
message="This avatar does not allow accessible documents mode",
)
# Check rate limit
if not check_rate_limit(avatar_id, requester.id, db_session):
return AvatarQueryResponse(
status="rate_limited",
message="You have exceeded the rate limit for this avatar",
)
# Log the query
log_avatar_query(
avatar_id=avatar_id,
requester_id=requester.id,
query_mode=query_mode,
query_text=query,
db_session=db_session,
)
if query_mode == AvatarQueryMode.OWNED_DOCUMENTS:
# Direct search on owned documents - no permission needed
filters = _build_owned_documents_filters(avatar, requester, db_session)
chunks = _execute_search(query, filters, db_session)
if not _has_good_results(chunks):
return AvatarQueryResponse(
status="no_results",
message="No relevant documents found",
)
# Generate answer from chunks using LLM
answer = _generate_answer(query, chunks, avatar)
return AvatarQueryResponse(
status="success",
answer=answer,
source_document_ids=[chunk.document_id for chunk in chunks],
)
elif query_mode == AvatarQueryMode.ACCESSIBLE_DOCUMENTS:
# Check auto-approve rules first
if should_auto_approve(avatar, requester):
# Execute search and return results directly
filters = _build_accessible_documents_filters(avatar, db_session)
chunks = _execute_search(query, filters, db_session)
if not _has_good_results(chunks):
return AvatarQueryResponse(
status="no_results",
message="No relevant documents found",
)
answer = _generate_answer(query, chunks, avatar)
return AvatarQueryResponse(
status="success",
answer=answer,
source_document_ids=[chunk.document_id for chunk in chunks],
)
# For non-auto-approved requests, run the query in the background
# Create permission request in PROCESSING status
permission_request = create_permission_request(
avatar_id=avatar_id,
requester_id=requester.id,
query_text=query if avatar.show_query_in_request else None,
db_session=db_session,
chat_session_id=chat_session_id,
chat_message_id=chat_message_id,
status=AvatarPermissionRequestStatus.PROCESSING,
)
# Commit to get the request ID before queuing the task
db_session.commit()
# Queue the background task via Celery client app
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.configs.constants import OnyxCeleryTask
task = client_app.send_task(
OnyxCeleryTask.AVATAR_QUERY_TASK,
kwargs={
"permission_request_id": permission_request.id,
"tenant_id": get_current_tenant_id() if MULTI_TENANT else None,
},
)
# Update with task ID
permission_request.task_id = task.id
db_session.commit()
logger.info(
f"Queued avatar query task {task.id} for permission request {permission_request.id}"
)
return AvatarQueryResponse(
status="processing",
permission_request_id=permission_request.id,
message=(
"Your query is being processed. You will be notified if an answer "
f"is found AND {avatar.user.email} approves the request.."
),
)
return AvatarQueryResponse(
status="error",
message="Invalid query mode",
)
def execute_broadcast_query(
avatar_ids: list[int],
query: str,
query_mode: AvatarQueryMode,
requester: User,
db_session: Session,
) -> dict[int, AvatarQueryResponse]:
"""Execute a query against multiple avatars.
Args:
avatar_ids: List of avatar IDs to query
query: The search query text
query_mode: Whether to search owned documents or all accessible documents
requester: The user making the request
db_session: Database session
Returns:
Dictionary mapping avatar_id to AvatarQueryResponse
"""
results = {}
for avatar_id in avatar_ids:
results[avatar_id] = execute_avatar_query(
avatar_id=avatar_id,
query=query,
query_mode=query_mode,
requester=requester,
db_session=db_session,
)
return results

View File

@@ -24,6 +24,7 @@ from onyx.context.search.models import SavedSearchDoc
from onyx.context.search.models import SavedSearchDocWithContent
from onyx.context.search.models import SearchDoc
from onyx.context.search.models import Tag
from onyx.db.enums import AvatarQueryMode
from onyx.db.enums import ChatSessionSharedStatus
from onyx.db.models import ChatSession
from onyx.file_store.models import FileDescriptor
@@ -143,8 +144,18 @@ class CreateChatMessageRequest(ChunkContext):
# TODO: make this a single one since unclear how to force this for multiple at a time.
forced_tool_ids: list[int] | None = None
# Avatar query mode - when set, routes the message through avatar query flow
# For single avatar query
avatar_id: int | None = None
# For broadcast mode (multiple avatars)
avatar_ids: list[int] | None = None
avatar_query_mode: AvatarQueryMode | None = None
@model_validator(mode="after")
def check_search_doc_ids_or_retrieval_options(self) -> "CreateChatMessageRequest":
# Avatar queries don't need search_doc_ids or retrieval_options
if self.avatar_id is not None or self.avatar_ids is not None:
return self
if self.search_doc_ids is None and self.retrieval_options is None:
raise ValueError(
"Either search_doc_ids or retrieval_options must be provided, but not both or neither."

View File

@@ -0,0 +1,277 @@
"use client";
import React, { useState } from "react";
import {
AvatarListItem,
AvatarQueryMode,
AvatarQueryResponse,
} from "@/lib/types";
import { useAvatarQuery } from "@/lib/avatar";
import { AvatarSelector } from "./AvatarSelector";
import {
Search,
Send,
Loader2,
CheckCircle,
Clock,
XCircle,
AlertCircle,
FileText,
} from "lucide-react";
export function AvatarQueryPanel() {
const [selectedAvatar, setSelectedAvatar] = useState<AvatarListItem | null>(
null
);
const [queryText, setQueryText] = useState("");
const [queryMode, setQueryMode] = useState<AvatarQueryMode>(
AvatarQueryMode.OWNED_DOCUMENTS
);
const { query, loading, error, result, clearResult } = useAvatarQuery();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedAvatar || !queryText.trim()) return;
try {
await query(selectedAvatar.id, queryText.trim(), queryMode);
} catch {
// Error is handled by the hook
}
};
const handleAvatarSelect = (avatar: AvatarListItem) => {
setSelectedAvatar(avatar);
clearResult();
};
return (
<div className="flex flex-col gap-6">
{/* Avatar Selection */}
<div className="bg-background-subtle p-4 rounded-lg">
<h3 className="text-lg font-semibold mb-3">
Select an Avatar to Query
</h3>
<AvatarSelector
onSelect={handleAvatarSelect}
selectedAvatarId={selectedAvatar?.id}
/>
</div>
{/* Query Form */}
{selectedAvatar && (
<div className="bg-background-subtle p-4 rounded-lg">
<h3 className="text-lg font-semibold mb-3">
Query {selectedAvatar.name || selectedAvatar.user_email}'s Knowledge
</h3>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Query Mode Selection */}
<div>
<label className="block text-sm font-medium mb-2">
Query Mode
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="queryMode"
checked={queryMode === AvatarQueryMode.OWNED_DOCUMENTS}
onChange={() =>
setQueryMode(AvatarQueryMode.OWNED_DOCUMENTS)
}
className="text-accent"
/>
<span className="text-sm">Owned Documents</span>
<span className="text-xs text-text-subtle">(Instant)</span>
</label>
{selectedAvatar.allow_accessible_mode && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="queryMode"
checked={
queryMode === AvatarQueryMode.ACCESSIBLE_DOCUMENTS
}
onChange={() =>
setQueryMode(AvatarQueryMode.ACCESSIBLE_DOCUMENTS)
}
className="text-accent"
/>
<span className="text-sm">All Accessible</span>
<span className="text-xs text-text-subtle">
(Requires Permission)
</span>
</label>
)}
</div>
</div>
{/* Query Input */}
<div>
<label className="block text-sm font-medium mb-2">
Your Question
</label>
<div className="relative">
<Search className="absolute left-3 top-3 h-5 w-5 text-text-subtle" />
<textarea
value={queryText}
onChange={(e) => setQueryText(e.target.value)}
placeholder="What would you like to know from this person's knowledge?"
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg bg-background min-h-[100px] resize-y focus:outline-none focus:ring-2 focus:ring-accent"
disabled={loading}
/>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading || !queryText.trim()}
className="flex items-center justify-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Querying...
</>
) : (
<>
<Send className="h-4 w-4" />
Send Query
</>
)}
</button>
</form>
</div>
)}
{/* Error Display */}
{error && (
<div className="bg-error/10 border border-error text-error p-4 rounded-lg flex items-center gap-2">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Result Display */}
{result && <QueryResultDisplay result={result} />}
</div>
);
}
interface QueryResultDisplayProps {
result: AvatarQueryResponse;
}
function QueryResultDisplay({ result }: QueryResultDisplayProps) {
const getStatusDisplay = () => {
switch (result.status) {
case "success":
return {
icon: <CheckCircle className="h-5 w-5 text-success" />,
title: "Query Successful",
bgColor: "bg-success/10",
borderColor: "border-success",
};
case "pending_permission":
return {
icon: <Clock className="h-5 w-5 text-warning" />,
title: "Permission Required",
bgColor: "bg-warning/10",
borderColor: "border-warning",
};
case "no_results":
return {
icon: <FileText className="h-5 w-5 text-text-subtle" />,
title: "No Results",
bgColor: "bg-background-subtle",
borderColor: "border-border",
};
case "rate_limited":
return {
icon: <AlertCircle className="h-5 w-5 text-warning" />,
title: "Rate Limited",
bgColor: "bg-warning/10",
borderColor: "border-warning",
};
case "disabled":
return {
icon: <XCircle className="h-5 w-5 text-error" />,
title: "Avatar Disabled",
bgColor: "bg-error/10",
borderColor: "border-error",
};
case "error":
default:
return {
icon: <XCircle className="h-5 w-5 text-error" />,
title: "Error",
bgColor: "bg-error/10",
borderColor: "border-error",
};
}
};
const status = getStatusDisplay();
return (
<div
className={`${status.bgColor} border ${status.borderColor} p-4 rounded-lg`}
>
<div className="flex items-center gap-2 mb-3">
{status.icon}
<h4 className="font-semibold">{status.title}</h4>
</div>
{result.message && (
<p className="text-sm text-text-subtle mb-3">{result.message}</p>
)}
{result.answer && (
<div className="bg-background p-4 rounded border border-border">
<h5 className="text-sm font-medium mb-2">Answer</h5>
<div className="text-sm whitespace-pre-wrap">{result.answer}</div>
</div>
)}
{result.source_document_ids && result.source_document_ids.length > 0 && (
<div className="mt-3">
<h5 className="text-sm font-medium mb-2">Sources</h5>
<div className="flex flex-wrap gap-2">
{result.source_document_ids.slice(0, 5).map((docId, idx) => (
<span
key={idx}
className="text-xs bg-background px-2 py-1 rounded border border-border"
>
{docId.slice(0, 20)}...
</span>
))}
{result.source_document_ids.length > 5 && (
<span className="text-xs text-text-subtle">
+{result.source_document_ids.length - 5} more
</span>
)}
</div>
</div>
)}
{result.permission_request_id && (
<div className="mt-3 p-3 bg-background rounded border border-border">
<p className="text-sm">
Your request has been sent! Request ID:{" "}
<code className="bg-background-subtle px-1 rounded">
#{result.permission_request_id}
</code>
</p>
<p className="text-xs text-text-subtle mt-1">
You'll be notified when the avatar owner responds.
</p>
</div>
)}
</div>
);
}
export default AvatarQueryPanel;

View File

@@ -0,0 +1,137 @@
"use client";
import React, { useState } from "react";
import { AvatarListItem, AvatarQueryMode } from "@/lib/types";
import { useQueryableAvatars } from "@/lib/avatar";
import { User, Search, Lock, Unlock } from "lucide-react";
interface AvatarSelectorProps {
onSelect: (avatar: AvatarListItem) => void;
selectedAvatarId?: number | null;
}
export function AvatarSelector({
onSelect,
selectedAvatarId,
}: AvatarSelectorProps) {
const { avatars, loading, error } = useQueryableAvatars();
const [searchTerm, setSearchTerm] = useState("");
const filteredAvatars = avatars.filter(
(avatar) =>
avatar.user_email.toLowerCase().includes(searchTerm.toLowerCase()) ||
(avatar.name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false)
);
if (loading) {
return (
<div className="p-4 text-center text-text-subtle">Loading avatars...</div>
);
}
if (error) {
return <div className="p-4 text-center text-error">{error}</div>;
}
if (avatars.length === 0) {
return (
<div className="p-4 text-center text-text-subtle">
No avatars available to query
</div>
);
}
return (
<div className="flex flex-col gap-2">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-subtle" />
<input
type="text"
placeholder="Search avatars..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-border rounded-lg bg-background text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Avatar list */}
<div className="max-h-64 overflow-y-auto border border-border rounded-lg">
{filteredAvatars.length === 0 ? (
<div className="p-4 text-center text-text-subtle">
No avatars match your search
</div>
) : (
filteredAvatars.map((avatar) => (
<AvatarListRow
key={avatar.id}
avatar={avatar}
isSelected={avatar.id === selectedAvatarId}
onClick={() => onSelect(avatar)}
/>
))
)}
</div>
</div>
);
}
interface AvatarListRowProps {
avatar: AvatarListItem;
isSelected: boolean;
onClick: () => void;
}
function AvatarListRow({ avatar, isSelected, onClick }: AvatarListRowProps) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 p-3 text-left hover:bg-hover transition-colors border-b border-border last:border-b-0 ${
isSelected ? "bg-accent/10" : ""
}`}
>
{/* Avatar icon */}
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center">
<User className="h-5 w-5 text-accent" />
</div>
{/* Avatar info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-text truncate">
{avatar.name || avatar.user_email}
</div>
{avatar.name && (
<div className="text-sm text-text-subtle truncate">
{avatar.user_email}
</div>
)}
{avatar.description && (
<div className="text-xs text-text-subtle truncate mt-0.5">
{avatar.description}
</div>
)}
</div>
{/* Query mode indicator */}
<div className="flex-shrink-0 flex items-center gap-1">
{avatar.allow_accessible_mode ? (
<span
className="flex items-center gap-1 text-xs text-success"
title="Supports accessible documents mode"
>
<Unlock className="h-3 w-3" />
</span>
) : (
<span
className="flex items-center gap-1 text-xs text-text-subtle"
title="Only owned documents mode"
>
<Lock className="h-3 w-3" />
</span>
)}
</div>
</button>
);
}
export default AvatarSelector;

View File

@@ -0,0 +1,277 @@
"use client";
import React, { useState, useEffect } from "react";
import { AvatarQueryMode, AvatarUpdateRequest } from "@/lib/types";
import { useMyAvatar } from "@/lib/avatar";
import {
Settings,
Save,
Loader2,
Eye,
EyeOff,
Shield,
Clock,
} from "lucide-react";
export function AvatarSettings() {
const { avatar, loading, error, updateAvatar } = useMyAvatar();
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
// Form state
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isEnabled, setIsEnabled] = useState(true);
const [defaultQueryMode, setDefaultQueryMode] = useState<AvatarQueryMode>(
AvatarQueryMode.OWNED_DOCUMENTS
);
const [allowAccessibleMode, setAllowAccessibleMode] = useState(true);
const [showQueryInRequest, setShowQueryInRequest] = useState(true);
const [maxRequestsPerDay, setMaxRequestsPerDay] = useState<number | null>(
100
);
// Sync form state with loaded avatar
useEffect(() => {
if (avatar) {
setName(avatar.name || "");
setDescription(avatar.description || "");
setIsEnabled(avatar.is_enabled);
setDefaultQueryMode(avatar.default_query_mode);
setAllowAccessibleMode(avatar.allow_accessible_mode);
setShowQueryInRequest(avatar.show_query_in_request);
setMaxRequestsPerDay(avatar.max_requests_per_day);
}
}, [avatar]);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaveSuccess(false);
const updates: AvatarUpdateRequest = {
name: name || null,
description: description || null,
is_enabled: isEnabled,
default_query_mode: defaultQueryMode,
allow_accessible_mode: allowAccessibleMode,
show_query_in_request: showQueryInRequest,
max_requests_per_day: maxRequestsPerDay,
};
try {
await updateAvatar(updates);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (err) {
setSaveError(
err instanceof Error ? err.message : "Failed to save settings"
);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
</div>
);
}
if (error) {
return (
<div className="p-4 bg-error/10 border border-error text-error rounded-lg">
{error}
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Settings className="h-6 w-6 text-accent" />
<h2 className="text-xl font-semibold">Avatar Settings</h2>
</div>
<div className="bg-background-subtle p-6 rounded-lg space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="font-medium text-lg border-b border-border pb-2">
Basic Information
</h3>
<div>
<label className="block text-sm font-medium mb-1">
Display Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Optional display name"
className="w-full px-3 py-2 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p className="text-xs text-text-subtle mt-1">
Leave empty to use your email address
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe your expertise or what others might find in your knowledge base"
className="w-full px-3 py-2 border border-border rounded-lg bg-background min-h-[80px] resize-y focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
{/* Availability */}
<div className="space-y-4">
<h3 className="font-medium text-lg border-b border-border pb-2 flex items-center gap-2">
<Eye className="h-5 w-5" />
Availability
</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
className="w-4 h-4 text-accent rounded"
/>
<div>
<span className="font-medium">Enable Avatar</span>
<p className="text-xs text-text-subtle">
When disabled, others cannot query your avatar
</p>
</div>
</label>
</div>
{/* Query Settings */}
<div className="space-y-4">
<h3 className="font-medium text-lg border-b border-border pb-2 flex items-center gap-2">
<Shield className="h-5 w-5" />
Query Permissions
</h3>
<div>
<label className="block text-sm font-medium mb-2">
Default Query Mode
</label>
<select
value={defaultQueryMode}
onChange={(e) =>
setDefaultQueryMode(e.target.value as AvatarQueryMode)
}
className="w-full px-3 py-2 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value={AvatarQueryMode.OWNED_DOCUMENTS}>
Owned Documents Only
</option>
<option value={AvatarQueryMode.ACCESSIBLE_DOCUMENTS}>
All Accessible Documents
</option>
</select>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={allowAccessibleMode}
onChange={(e) => setAllowAccessibleMode(e.target.checked)}
className="w-4 h-4 text-accent rounded"
/>
<div>
<span className="font-medium">Allow "All Accessible" Mode</span>
<p className="text-xs text-text-subtle">
Let others request to query all documents you can access (with
your approval)
</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={showQueryInRequest}
onChange={(e) => setShowQueryInRequest(e.target.checked)}
className="w-4 h-4 text-accent rounded"
/>
<div>
<span className="font-medium">Show Query in Requests</span>
<p className="text-xs text-text-subtle">
See what others are asking when they request permission
</p>
</div>
</label>
</div>
{/* Rate Limiting */}
<div className="space-y-4">
<h3 className="font-medium text-lg border-b border-border pb-2 flex items-center gap-2">
<Clock className="h-5 w-5" />
Rate Limiting
</h3>
<div>
<label className="block text-sm font-medium mb-1">
Max Requests Per Day (per user)
</label>
<input
type="number"
value={maxRequestsPerDay ?? ""}
onChange={(e) =>
setMaxRequestsPerDay(
e.target.value ? parseInt(e.target.value) : null
)
}
placeholder="Unlimited"
min={0}
className="w-full px-3 py-2 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p className="text-xs text-text-subtle mt-1">
Leave empty for unlimited requests
</p>
</div>
</div>
{/* Save Button */}
<div className="flex items-center gap-4 pt-4 border-t border-border">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent-hover disabled:opacity-50 transition-colors"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
Save Settings
</>
)}
</button>
{saveSuccess && (
<span className="text-success text-sm">Settings saved!</span>
)}
{saveError && <span className="text-error text-sm">{saveError}</span>}
</div>
</div>
</div>
);
}
export default AvatarSettings;

View File

@@ -0,0 +1,432 @@
"use client";
import React, { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { PermissionRequest, AvatarPermissionRequestStatus } from "@/lib/types";
import {
useIncomingPermissionRequests,
useOutgoingPermissionRequests,
} from "@/lib/avatar";
import {
Inbox,
Send,
CheckCircle,
XCircle,
Clock,
AlertCircle,
Loader2,
User,
MessageSquare,
} from "lucide-react";
type TabType = "incoming" | "outgoing";
function isValidSubTab(tab: string | null): tab is TabType {
return tab === "incoming" || tab === "outgoing";
}
export function PermissionRequests() {
const searchParams = useSearchParams();
const subtabParam = searchParams.get("subtab");
const initialSubTab = isValidSubTab(subtabParam) ? subtabParam : "outgoing";
const [activeTab, setActiveTab] = useState<TabType>(initialSubTab);
// Update state if URL changes externally
useEffect(() => {
if (isValidSubTab(subtabParam) && subtabParam !== activeTab) {
setActiveTab(subtabParam);
}
}, [subtabParam, activeTab]);
return (
<div className="flex flex-col gap-4">
{/* Tab Navigation */}
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("incoming")}
className={`flex items-center gap-2 px-4 py-2 border-b-2 transition-colors ${
activeTab === "incoming"
? "border-accent text-accent"
: "border-transparent text-text-subtle hover:text-text"
}`}
>
<Inbox className="h-4 w-4" />
Incoming Requests
</button>
<button
onClick={() => setActiveTab("outgoing")}
className={`flex items-center gap-2 px-4 py-2 border-b-2 transition-colors ${
activeTab === "outgoing"
? "border-accent text-accent"
: "border-transparent text-text-subtle hover:text-text"
}`}
>
<Send className="h-4 w-4" />
My Requests
</button>
</div>
{/* Tab Content */}
{activeTab === "incoming" ? (
<IncomingRequestsList />
) : (
<OutgoingRequestsList />
)}
</div>
);
}
function IncomingRequestsList() {
const { requests, loading, error, approve, deny } =
useIncomingPermissionRequests();
const [processingId, setProcessingId] = useState<number | null>(null);
const [denyReason, setDenyReason] = useState<string>("");
const [showDenyModal, setShowDenyModal] = useState<number | null>(null);
const handleApprove = async (requestId: number) => {
setProcessingId(requestId);
try {
await approve(requestId);
} catch {
// Error handled by hook
} finally {
setProcessingId(null);
}
};
const handleDeny = async (requestId: number) => {
setProcessingId(requestId);
try {
await deny(requestId, denyReason || undefined);
setShowDenyModal(null);
setDenyReason("");
} catch {
// Error handled by hook
} finally {
setProcessingId(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
);
}
if (error) {
return (
<div className="p-4 bg-error/10 border border-error text-error rounded-lg flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{error}
</div>
);
}
if (requests.length === 0) {
return (
<div className="p-8 text-center text-text-subtle">
<Inbox className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>No pending permission requests</p>
</div>
);
}
return (
<div className="space-y-3">
{requests.map((request) => (
<div
key={request.id}
className="bg-background-subtle p-4 rounded-lg border border-border"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<User className="h-4 w-4 text-text-subtle" />
<span className="font-medium">{request.requester_email}</span>
<StatusBadge status={request.status} />
</div>
{request.query_text && (
<div className="bg-background p-3 rounded border border-border mb-3">
<div className="flex items-center gap-2 text-xs text-text-subtle mb-1">
<MessageSquare className="h-3 w-3" />
Query
</div>
<p className="text-sm">{request.query_text}</p>
</div>
)}
<div className="text-xs text-text-subtle">
Requested {formatDate(request.created_at)} Expires{" "}
{formatDate(request.expires_at)}
</div>
</div>
{request.status === AvatarPermissionRequestStatus.PENDING && (
<div className="flex gap-2">
<button
onClick={() => handleApprove(request.id)}
disabled={processingId === request.id}
className="flex items-center gap-1 px-3 py-1.5 bg-success text-white rounded hover:bg-success/90 disabled:opacity-50 text-sm"
>
{processingId === request.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<CheckCircle className="h-3 w-3" />
)}
Approve
</button>
<button
onClick={() => setShowDenyModal(request.id)}
disabled={processingId === request.id}
className="flex items-center gap-1 px-3 py-1.5 bg-error text-white rounded hover:bg-error/90 disabled:opacity-50 text-sm"
>
<XCircle className="h-3 w-3" />
Deny
</button>
</div>
)}
</div>
{/* Deny Modal */}
{showDenyModal === request.id && (
<div className="mt-4 p-4 bg-background rounded border border-border">
<label className="block text-sm font-medium mb-2">
Reason for denial (optional)
</label>
<textarea
value={denyReason}
onChange={(e) => setDenyReason(e.target.value)}
placeholder="Enter a reason..."
className="w-full px-3 py-2 border border-border rounded bg-background text-sm resize-none h-20 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<div className="flex gap-2 mt-3">
<button
onClick={() => handleDeny(request.id)}
disabled={processingId === request.id}
className="px-3 py-1.5 bg-error text-white rounded text-sm hover:bg-error/90 disabled:opacity-50"
>
{processingId === request.id ? "Denying..." : "Confirm Deny"}
</button>
<button
onClick={() => {
setShowDenyModal(null);
setDenyReason("");
}}
className="px-3 py-1.5 bg-background-subtle border border-border rounded text-sm hover:bg-hover"
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
</div>
);
}
function OutgoingRequestsList() {
const { requests, loading, error } = useOutgoingPermissionRequests();
const [expandedAnswers, setExpandedAnswers] = useState<Set<number>>(
new Set()
);
const toggleAnswer = (requestId: number) => {
setExpandedAnswers((prev) => {
const newSet = new Set(prev);
if (newSet.has(requestId)) {
newSet.delete(requestId);
} else {
newSet.add(requestId);
}
return newSet;
});
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
);
}
if (error) {
return (
<div className="p-4 bg-error/10 border border-error text-error rounded-lg flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{error}
</div>
);
}
if (requests.length === 0) {
return (
<div className="p-8 text-center text-text-subtle">
<Send className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>You haven't made any permission requests</p>
</div>
);
}
return (
<div className="space-y-3">
{requests.map((request) => (
<div
key={request.id}
className="bg-background-subtle p-4 rounded-lg border border-border"
>
<div className="flex items-center gap-2 mb-2">
<User className="h-4 w-4 text-text-subtle" />
<span className="font-medium">{request.avatar_user_email}</span>
<StatusBadge status={request.status} />
</div>
{request.query_text && (
<div className="bg-background p-3 rounded border border-border mb-3">
<div className="flex items-center gap-2 text-xs text-text-subtle mb-1">
<MessageSquare className="h-3 w-3" />
Your Query
</div>
<p className="text-sm">{request.query_text}</p>
</div>
)}
{/* Show View Answer button for approved requests */}
{request.status === AvatarPermissionRequestStatus.APPROVED &&
request.cached_answer && (
<div className="mb-3">
<button
onClick={() => toggleAnswer(request.id)}
className="flex items-center gap-2 px-3 py-1.5 bg-success/10 text-success border border-success/30 rounded hover:bg-success/20 transition-colors text-sm"
>
<CheckCircle className="h-4 w-4" />
{expandedAnswers.has(request.id)
? "Hide Answer"
: "View Answer"}
</button>
{expandedAnswers.has(request.id) && (
<div className="mt-3 bg-background p-4 rounded border border-success/30">
<div className="text-xs font-medium text-success mb-2">
Approved Answer
</div>
<div className="text-sm whitespace-pre-wrap">
{request.cached_answer}
</div>
{request.cached_search_doc_ids &&
request.cached_search_doc_ids.length > 0 && (
<div className="mt-3 pt-3 border-t border-border">
<div className="text-xs font-medium text-text-subtle mb-2">
Source Chunks (
{request.cached_search_doc_ids.length})
</div>
<div className="flex flex-wrap gap-2">
{request.cached_search_doc_ids
.slice(0, 5)
.map((chunkId, idx) => (
<span
key={idx}
className="text-xs bg-background-subtle px-2 py-1 rounded border border-border"
>
Chunk #{chunkId}
</span>
))}
{request.cached_search_doc_ids.length > 5 && (
<span className="text-xs text-text-subtle">
+{request.cached_search_doc_ids.length - 5} more
</span>
)}
</div>
</div>
)}
</div>
)}
</div>
)}
{request.denial_reason && (
<div className="bg-error/10 p-3 rounded border border-error/50 mb-3">
<div className="text-xs text-error mb-1">Denial Reason</div>
<p className="text-sm">{request.denial_reason}</p>
</div>
)}
<div className="text-xs text-text-subtle">
Requested {formatDate(request.created_at)}
{request.resolved_at &&
` • Resolved ${formatDate(request.resolved_at)}`}
</div>
</div>
))}
</div>
);
}
function StatusBadge({ status }: { status: AvatarPermissionRequestStatus }) {
const config = {
[AvatarPermissionRequestStatus.PENDING]: {
icon: <Clock className="h-3 w-3" />,
text: "Pending",
className: "bg-warning/20 text-warning",
},
[AvatarPermissionRequestStatus.PROCESSING]: {
icon: <Loader2 className="h-3 w-3 animate-spin" />,
text: "Processing",
className: "bg-accent/20 text-accent",
},
[AvatarPermissionRequestStatus.APPROVED]: {
icon: <CheckCircle className="h-3 w-3" />,
text: "Approved",
className: "bg-success/20 text-success",
},
[AvatarPermissionRequestStatus.DENIED]: {
icon: <XCircle className="h-3 w-3" />,
text: "Denied",
className: "bg-error/20 text-error",
},
[AvatarPermissionRequestStatus.EXPIRED]: {
icon: <Clock className="h-3 w-3" />,
text: "Expired",
className: "bg-text-subtle/20 text-text-subtle",
},
[AvatarPermissionRequestStatus.NO_ANSWER]: {
icon: <AlertCircle className="h-3 w-3" />,
text: "No Answer",
className: "bg-text-subtle/20 text-text-subtle",
},
};
const { icon, text, className } = config[status];
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ${className}`}
>
{icon}
{text}
</span>
);
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
export default PermissionRequests;

View File

@@ -0,0 +1,97 @@
"use client";
import React, { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { AvatarQueryPanel } from "./components/AvatarQueryPanel";
import { AvatarSettings } from "./components/AvatarSettings";
import { PermissionRequests } from "./components/PermissionRequests";
import { Search, Settings, Bell, Users } from "lucide-react";
type TabType = "query" | "requests" | "settings";
function isValidTab(tab: string | null): tab is TabType {
return tab === "query" || tab === "requests" || tab === "settings";
}
export default function AvatarsPage() {
const searchParams = useSearchParams();
const router = useRouter();
const tabParam = searchParams.get("tab");
const initialTab = isValidTab(tabParam) ? tabParam : "query";
const [activeTab, setActiveTab] = useState<TabType>(initialTab);
// Sync URL when tab changes
const handleTabChange = (tab: TabType) => {
setActiveTab(tab);
router.replace(`/avatars?tab=${tab}`, { scroll: false });
};
// Update state if URL changes externally
useEffect(() => {
if (isValidTab(tabParam) && tabParam !== activeTab) {
setActiveTab(tabParam);
}
}, [tabParam, activeTab]);
return (
<div className="min-h-screen bg-background">
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Users className="h-8 w-8 text-accent" />
<h1 className="text-2xl font-bold">Avatars</h1>
</div>
<p className="text-text-subtle">
Query other users' knowledge bases or manage your own avatar
settings
</p>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border mb-6">
<button
onClick={() => handleTabChange("query")}
className={`flex items-center gap-2 px-4 py-3 border-b-2 transition-colors ${
activeTab === "query"
? "border-accent text-accent"
: "border-transparent text-text-subtle hover:text-text"
}`}
>
<Search className="h-4 w-4" />
Query Avatars
</button>
<button
onClick={() => handleTabChange("requests")}
className={`flex items-center gap-2 px-4 py-3 border-b-2 transition-colors ${
activeTab === "requests"
? "border-accent text-accent"
: "border-transparent text-text-subtle hover:text-text"
}`}
>
<Bell className="h-4 w-4" />
Permission Requests
</button>
<button
onClick={() => handleTabChange("settings")}
className={`flex items-center gap-2 px-4 py-3 border-b-2 transition-colors ${
activeTab === "settings"
? "border-accent text-accent"
: "border-transparent text-text-subtle hover:text-text"
}`}
>
<Settings className="h-4 w-4" />
My Avatar Settings
</button>
</div>
{/* Tab Content */}
<div>
{activeTab === "query" && <AvatarQueryPanel />}
{activeTab === "requests" && <PermissionRequests />}
{activeTab === "settings" && <AvatarSettings />}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
import {
AvatarListItem,
AvatarQueryMode,
AvatarQueryResponse,
} from "@/lib/types";
interface AvatarContextState {
// Avatar mode state
isAvatarMode: boolean;
isBroadcastMode: boolean;
selectedAvatar: AvatarListItem | null; // Single avatar for non-broadcast
selectedAvatars: AvatarListItem[]; // Multiple avatars for broadcast
queryMode: AvatarQueryMode;
// Query state (legacy - kept for backward compatibility)
isQuerying: boolean;
lastResult: AvatarQueryResponse | null;
queryError: string | null;
// Actions
enableAvatarMode: (avatar: AvatarListItem) => void;
disableAvatarMode: () => void;
setQueryMode: (mode: AvatarQueryMode) => void;
clearResult: () => void;
// Broadcast mode actions - automatically includes all avatars
enableBroadcastMode: (allAvatars: AvatarListItem[]) => void;
toggleAvatarSelection: (avatar: AvatarListItem) => void;
selectAllAvatars: (avatars: AvatarListItem[]) => void;
clearAvatarSelection: () => void;
}
const AvatarContext = createContext<AvatarContextState | null>(null);
export function AvatarProvider({ children }: { children: ReactNode }) {
const [isAvatarMode, setIsAvatarMode] = useState(false);
const [isBroadcastMode, setIsBroadcastMode] = useState(false);
const [selectedAvatar, setSelectedAvatar] = useState<AvatarListItem | null>(
null
);
const [selectedAvatars, setSelectedAvatars] = useState<AvatarListItem[]>([]);
const [queryMode, setQueryMode] = useState<AvatarQueryMode>(
AvatarQueryMode.OWNED_DOCUMENTS
);
const [isQuerying, setIsQuerying] = useState(false);
const [lastResult, setLastResult] = useState<AvatarQueryResponse | null>(
null
);
const [queryError, setQueryError] = useState<string | null>(null);
// Single avatar mode
const enableAvatarMode = useCallback((avatar: AvatarListItem) => {
setIsAvatarMode(true);
setIsBroadcastMode(false);
setSelectedAvatar(avatar);
setSelectedAvatars([]);
setQueryMode(avatar.default_query_mode);
setLastResult(null);
setQueryError(null);
}, []);
const disableAvatarMode = useCallback(() => {
setIsAvatarMode(false);
setIsBroadcastMode(false);
setSelectedAvatar(null);
setSelectedAvatars([]);
setLastResult(null);
setQueryError(null);
}, []);
// Broadcast mode actions - automatically includes all avatars
const enableBroadcastMode = useCallback((allAvatars: AvatarListItem[]) => {
setIsAvatarMode(true);
setIsBroadcastMode(true);
setSelectedAvatar(null);
// Automatically select all avatars for broadcast
setSelectedAvatars(allAvatars);
setLastResult(null);
setQueryError(null);
}, []);
const toggleAvatarSelection = useCallback((avatar: AvatarListItem) => {
setSelectedAvatars((prev) => {
const isSelected = prev.some((a) => a.id === avatar.id);
if (isSelected) {
return prev.filter((a) => a.id !== avatar.id);
} else {
return [...prev, avatar];
}
});
}, []);
const selectAllAvatars = useCallback((avatars: AvatarListItem[]) => {
setSelectedAvatars(avatars);
}, []);
const clearAvatarSelection = useCallback(() => {
setSelectedAvatars([]);
}, []);
const clearResult = useCallback(() => {
setLastResult(null);
setQueryError(null);
}, []);
return (
<AvatarContext.Provider
value={{
isAvatarMode,
isBroadcastMode,
selectedAvatar,
selectedAvatars,
queryMode,
isQuerying,
lastResult,
queryError,
enableAvatarMode,
disableAvatarMode,
setQueryMode,
clearResult,
enableBroadcastMode,
toggleAvatarSelection,
selectAllAvatars,
clearAvatarSelection,
}}
>
{children}
</AvatarContext.Provider>
);
}
export function useAvatarContext() {
const context = useContext(AvatarContext);
if (!context) {
throw new Error("useAvatarContext must be used within an AvatarProvider");
}
return context;
}
export function useAvatarContextOptional() {
return useContext(AvatarContext);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { ReactNode } from "react";
import { AvatarProvider } from "./AvatarContext";
export default function AvatarProviderWrapper({
children,
}: {
children: ReactNode;
}) {
return <AvatarProvider>{children}</AvatarProvider>;
}

View File

@@ -0,0 +1,6 @@
export {
AvatarProvider,
useAvatarContext,
useAvatarContextOptional,
} from "./AvatarContext";
export { default as AvatarProviderWrapper } from "./AvatarProviderWrapper";

View File

@@ -81,6 +81,8 @@ import AppPageLayout from "@/layouts/AppPageLayout";
import { HeaderData } from "@/lib/headers/fetchHeaderDataSS";
import IconButton from "@/refresh-components/buttons/IconButton";
import SvgChevronDown from "@/icons/chevron-down";
import { AvatarModeIndicator } from "@/app/chat/components/avatar/AvatarModeIndicator";
import { useAvatarContextOptional } from "@/app/chat/avatars/AvatarContext";
const DEFAULT_CONTEXT_TOKENS = 120_000;
@@ -132,6 +134,9 @@ export default function ChatPage({
const { height: screenHeight } = useScreenSize();
// Avatar context for avatar query mode
const avatarContext = useAvatarContextOptional();
// handle redirect if chat page is disabled
// NOTE: this must be done here, in a client component since
// settings are passed in via Context and therefore aren't
@@ -630,14 +635,37 @@ export default function ChatPage({
);
}, []);
const handleChatInputSubmit = useCallback(() => {
const handleChatInputSubmit = useCallback(async () => {
// Submit message through normal chat flow
// If in avatar mode, include avatar parameters
const isAvatarMode = avatarContext?.isAvatarMode;
const isBroadcastMode = avatarContext?.isBroadcastMode;
onSubmit({
message: message,
currentMessageFiles: currentMessageFiles,
useAgentSearch: deepResearchEnabled,
// Pass avatar parameters if in avatar mode
// For single avatar mode
avatarId:
isAvatarMode && !isBroadcastMode
? avatarContext?.selectedAvatar?.id
: undefined,
// For broadcast mode (multiple avatars)
avatarIds:
isAvatarMode && isBroadcastMode && avatarContext?.selectedAvatars
? avatarContext.selectedAvatars.map((a) => a.id)
: undefined,
avatarQueryMode: isAvatarMode ? avatarContext?.queryMode : undefined,
});
setShowOnboarding(false);
}, [message, onSubmit, currentMessageFiles, deepResearchEnabled]);
}, [
message,
onSubmit,
currentMessageFiles,
deepResearchEnabled,
avatarContext,
]);
// Memoized callbacks for DocumentResults
const handleMobileDocumentSidebarClose = useCallback(() => {
@@ -937,6 +965,8 @@ export default function ChatPage({
llmDescriptors={llmDescriptors}
/>
)}
{/* Avatar indicator above input when input is at bottom */}
{!showCenteredInput && <AvatarModeIndicator />}
<ChatInputBar
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
@@ -971,6 +1001,8 @@ export default function ChatPage({
OnboardingStep.Complete)
}
/>
{/* Avatar indicator below input when input is centered */}
{showCenteredInput && <AvatarModeIndicator />}
</div>
{currentProjectId !== null && (

View File

@@ -10,6 +10,7 @@ import { EnterpriseSettings } from "@/app/admin/settings/interfaces";
import { FileDescriptor } from "@/app/chat/interfaces";
import { MemoizedAIMessage } from "../message/messageComponents/MemoizedAIMessage";
import { ProjectFile } from "../projects/projectsService";
import { ChatSessionAvatarRequests } from "./avatar/ChatSessionAvatarRequests";
interface MessagesDisplayProps {
messageHistory: Message[];
@@ -218,6 +219,9 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
</div>
))}
{/* Avatar permission requests for this chat session */}
<ChatSessionAvatarRequests chatSessionId={chatSessionId} />
{messageHistory.length > 0 && (
<div
style={{

View File

@@ -0,0 +1,114 @@
"use client";
import React from "react";
import { useAvatarContextOptional } from "@/app/chat/avatars/AvatarContext";
import { AvatarQueryMode } from "@/lib/types";
import { User, X, Lock, Unlock, Radio } from "lucide-react";
import { cn } from "@/lib/utils";
export function AvatarModeIndicator() {
const avatarContext = useAvatarContextOptional();
if (!avatarContext || !avatarContext.isAvatarMode) {
return null;
}
const {
isBroadcastMode,
selectedAvatar,
selectedAvatars,
queryMode,
setQueryMode,
disableAvatarMode,
} = avatarContext;
// Don't show if no avatar selected (single mode)
if (!isBroadcastMode && !selectedAvatar) {
return null;
}
// Check if all selected avatars allow accessible mode
const allAllowAccessible = isBroadcastMode
? selectedAvatars.every((a) => a.allow_accessible_mode)
: selectedAvatar?.allow_accessible_mode;
return (
<div className="w-full max-w-[50rem] my-2">
<div className="bg-background border border-accent/20 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center">
{isBroadcastMode ? (
<Radio className="h-4 w-4 text-accent" />
) : (
<User className="h-4 w-4 text-accent" />
)}
</div>
<div>
<div className="text-sm font-medium text-accent">
{isBroadcastMode ? "Broadcast" : "Avatar Query"}
</div>
<div className="text-xs text-text-subtle">
{isBroadcastMode
? "Asking everyone at the company"
: `Asking: ${
selectedAvatar?.name || selectedAvatar?.user_email
}`}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Query Mode Toggle */}
<div className="flex bg-background rounded border border-border">
<button
onClick={() => setQueryMode(AvatarQueryMode.OWNED_DOCUMENTS)}
className={cn(
"flex items-center gap-1 px-2 py-1 text-xs rounded-l transition-colors",
queryMode === AvatarQueryMode.OWNED_DOCUMENTS
? "bg-accent text-white"
: "text-text-subtle hover:text-text"
)}
>
<Lock className="h-3 w-3" />
Owned
</button>
{allAllowAccessible && (
<button
onClick={() =>
setQueryMode(AvatarQueryMode.ACCESSIBLE_DOCUMENTS)
}
className={cn(
"flex items-center gap-1 px-2 py-1 text-xs rounded-r transition-colors",
queryMode === AvatarQueryMode.ACCESSIBLE_DOCUMENTS
? "bg-accent text-white"
: "text-text-subtle hover:text-text"
)}
>
<Unlock className="h-3 w-3" />
All
</button>
)}
</div>
{/* Close button */}
<button
onClick={disableAvatarMode}
className="p-1 hover:bg-background rounded transition-colors"
title="Exit Avatar Mode"
>
<X className="h-4 w-4 text-text-subtle hover:text-text" />
</button>
</div>
</div>
{queryMode === AvatarQueryMode.ACCESSIBLE_DOCUMENTS && (
<div className="mt-2 text-xs text-warning bg-warning/10 px-2 py-1 rounded">
Note: Accessible mode queries require permission from the avatar
owner{isBroadcastMode && "s"}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
import React from "react";
import Link from "next/link";
import { useAvatarContextOptional } from "@/app/chat/avatars/AvatarContext";
import { AvatarQueryResponse } from "@/lib/types";
import {
CheckCircle,
Clock,
XCircle,
AlertCircle,
FileText,
X,
Loader2,
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
export function AvatarQueryResult() {
const avatarContext = useAvatarContextOptional();
if (!avatarContext) {
return null;
}
const { isQuerying, lastResult, queryError, clearResult, selectedAvatar } =
avatarContext;
// Show loading state
if (isQuerying) {
return (
<div className="w-full max-w-[50rem] mb-4">
<div className="bg-background-subtle border border-border rounded-lg p-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-accent" />
<div>
<div className="text-sm font-medium">Querying avatar...</div>
<div className="text-xs text-text-subtle">
Searching {selectedAvatar?.name || selectedAvatar?.user_email}'s
knowledge
</div>
</div>
</div>
</div>
</div>
);
}
// Show error state
if (queryError) {
return (
<div className="w-full max-w-[50rem] mb-4">
<div className="bg-error/10 border border-error rounded-lg p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<XCircle className="h-5 w-5 text-error flex-shrink-0 mt-0.5" />
<div>
<div className="text-sm font-medium text-error">
Query Failed
</div>
<div className="text-xs text-error/80 mt-1">{queryError}</div>
</div>
</div>
<button
onClick={clearResult}
className="p-1 hover:bg-error/10 rounded transition-colors"
>
<X className="h-4 w-4 text-error" />
</button>
</div>
</div>
</div>
);
}
// Show result
if (!lastResult) {
return null;
}
return (
<div className="w-full max-w-[50rem] mb-4">
<QueryResultCard result={lastResult} onClose={clearResult} />
</div>
);
}
interface QueryResultCardProps {
result: AvatarQueryResponse;
onClose: () => void;
}
function QueryResultCard({ result, onClose }: QueryResultCardProps) {
const getStatusDisplay = () => {
switch (result.status) {
case "success":
return {
icon: <CheckCircle className="h-5 w-5 text-success" />,
title: "Query Successful",
bgColor: "bg-success/10",
borderColor: "border-success/30",
};
case "pending_permission":
return {
icon: <Clock className="h-5 w-5 text-warning" />,
title: "Permission Required",
bgColor: "bg-warning/10",
borderColor: "border-warning/30",
};
case "no_results":
return {
icon: <FileText className="h-5 w-5 text-text-subtle" />,
title: "No Results",
bgColor: "bg-background-subtle",
borderColor: "border-border",
};
case "rate_limited":
return {
icon: <AlertCircle className="h-5 w-5 text-warning" />,
title: "Rate Limited",
bgColor: "bg-warning/10",
borderColor: "border-warning/30",
};
case "disabled":
return {
icon: <XCircle className="h-5 w-5 text-error" />,
title: "Avatar Disabled",
bgColor: "bg-error/10",
borderColor: "border-error/30",
};
case "error":
default:
return {
icon: <XCircle className="h-5 w-5 text-error" />,
title: "Error",
bgColor: "bg-error/10",
borderColor: "border-error/30",
};
}
};
const status = getStatusDisplay();
return (
<div
className={cn(
status.bgColor,
"border",
status.borderColor,
"rounded-lg p-4"
)}
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2">
{status.icon}
<h4 className="font-semibold text-sm">{status.title}</h4>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-background rounded transition-colors"
>
<X className="h-4 w-4 text-text-subtle" />
</button>
</div>
{result.message && (
<p className="text-sm text-text-subtle mb-3">{result.message}</p>
)}
{result.answer && (
<div className="bg-background p-4 rounded border border-border">
<h5 className="text-xs font-medium text-text-subtle mb-2">Answer</h5>
<div className="text-sm whitespace-pre-wrap">{result.answer}</div>
</div>
)}
{result.source_document_ids && result.source_document_ids.length > 0 && (
<div className="mt-3">
<h5 className="text-xs font-medium text-text-subtle mb-2">Sources</h5>
<div className="flex flex-wrap gap-2">
{result.source_document_ids.slice(0, 5).map((docId, idx) => (
<span
key={idx}
className="text-xs bg-background px-2 py-1 rounded border border-border"
>
{docId.length > 20 ? `${docId.slice(0, 20)}...` : docId}
</span>
))}
{result.source_document_ids.length > 5 && (
<span className="text-xs text-text-subtle">
+{result.source_document_ids.length - 5} more
</span>
)}
</div>
</div>
)}
{result.permission_request_id && (
<div className="mt-3 p-3 bg-background rounded border border-border">
<p className="text-sm">
Your request has been sent! Request ID:{" "}
<code className="bg-background-subtle px-1 rounded text-xs">
#{result.permission_request_id}
</code>
</p>
<p className="text-xs text-text-subtle mt-1">
You'll be notified when the avatar owner responds.
</p>
<Link
href="/avatars?tab=requests"
className="inline-flex items-center gap-1 mt-2 text-xs text-accent hover:underline"
>
<ExternalLink className="h-3 w-3" />
View My Requests
</Link>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import React from "react";
import { useChatSessionPermissionRequests } from "@/lib/avatar";
import { AvatarPermissionRequestStatus } from "@/lib/types";
import { CheckCircle, Clock, XCircle, User, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
interface ChatSessionAvatarRequestsProps {
chatSessionId: string | null;
}
export function ChatSessionAvatarRequests({
chatSessionId,
}: ChatSessionAvatarRequestsProps) {
const { requests, loading, refresh } =
useChatSessionPermissionRequests(chatSessionId);
// Don't render if no requests
if (!chatSessionId || requests.length === 0) {
return null;
}
// Group requests by status
const approvedRequests = requests.filter(
(r) => r.status === AvatarPermissionRequestStatus.APPROVED
);
const pendingRequests = requests.filter(
(r) => r.status === AvatarPermissionRequestStatus.PENDING
);
const deniedRequests = requests.filter(
(r) => r.status === AvatarPermissionRequestStatus.DENIED
);
return (
<div className="w-full max-w-4xl mx-auto mb-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between px-1">
<h3 className="text-sm font-medium text-text-subtle flex items-center gap-2">
<User className="h-4 w-4" />
Avatar Query Results
</h3>
<button
onClick={refresh}
disabled={loading}
className="p-1 hover:bg-background-subtle rounded transition-colors"
title="Refresh status"
>
<RefreshCw
className={cn(
"h-4 w-4 text-text-subtle",
loading && "animate-spin"
)}
/>
</button>
</div>
{/* Approved Requests - Show answer directly */}
{approvedRequests.map((request) => (
<div
key={request.id}
className="bg-success/5 border border-success/30 rounded-lg overflow-hidden"
>
<div className="px-4 py-3">
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="h-5 w-5 text-success flex-shrink-0" />
<div>
<div className="text-sm font-medium">
Answer from {request.avatar_user_email}
</div>
{request.query_text && (
<div className="text-xs text-text-subtle">
Query: {request.query_text}
</div>
)}
</div>
</div>
{request.cached_answer && (
<div className="bg-background p-4 rounded border border-border">
<div className="text-sm whitespace-pre-wrap leading-relaxed">
{request.cached_answer}
</div>
</div>
)}
</div>
</div>
))}
{/* Pending Requests */}
{pendingRequests.map((request) => (
<div
key={request.id}
className="bg-warning/5 border border-warning/30 rounded-lg px-4 py-3"
>
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-warning" />
<div>
<div className="text-sm font-medium">
Awaiting approval from {request.avatar_user_email}
</div>
{request.query_text && (
<div className="text-xs text-text-subtle truncate max-w-md">
Query: {request.query_text}
</div>
)}
<div className="text-xs text-warning mt-1">
You'll see the answer here once approved
</div>
</div>
</div>
</div>
))}
{/* Denied Requests */}
{deniedRequests.map((request) => (
<div
key={request.id}
className="bg-error/5 border border-error/30 rounded-lg px-4 py-3"
>
<div className="flex items-center gap-3">
<XCircle className="h-5 w-5 text-error" />
<div>
<div className="text-sm font-medium">
Request denied by {request.avatar_user_email}
</div>
{request.denial_reason && (
<div className="text-xs text-error mt-1">
Reason: {request.denial_reason}
</div>
)}
</div>
</div>
</div>
))}
</div>
);
}
export default ChatSessionAvatarRequests;

View File

@@ -0,0 +1,2 @@
export { AvatarModeIndicator } from "./AvatarModeIndicator";
export { AvatarQueryResult } from "./AvatarQueryResult";

View File

@@ -95,6 +95,11 @@ export interface OnSubmitProps {
modelOverride?: LlmDescriptor;
regenerationRequest?: RegenerationRequest | null;
overrideFileDescriptors?: FileDescriptor[];
// Avatar query parameters
avatarId?: number;
avatarIds?: number[]; // For broadcast mode
avatarQueryMode?: string;
}
interface RegenerationRequest {
@@ -396,6 +401,9 @@ export function useChatController({
modelOverride,
regenerationRequest,
overrideFileDescriptors,
avatarId,
avatarIds,
avatarQueryMode,
}: OnSubmitProps) => {
const projectId = params(SEARCH_PARAM_NAMES.PROJECT_ID);
{
@@ -715,6 +723,9 @@ export function useChatController({
.map((tool) => tool.id)
: undefined,
forcedToolIds: forcedToolIds,
avatarId,
avatarIds,
avatarQueryMode,
});
const delay = (ms: number) => {

View File

@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { unstable_noStore as noStore } from "next/cache";
import { requireAuth } from "@/lib/auth/requireAuth";
import { ProjectsProvider } from "./projects/ProjectsContext";
import AvatarProviderWrapper from "./avatars/AvatarProviderWrapper";
import AppSidebar from "@/sections/sidebar/AppSidebar";
export interface LayoutProps {
@@ -20,10 +21,12 @@ export default async function Layout({ children }: LayoutProps) {
return (
<ProjectsProvider>
<div className="flex flex-row w-full h-full">
<AppSidebar />
{children}
</div>
<AvatarProviderWrapper>
<div className="flex flex-row w-full h-full">
<AppSidebar />
{children}
</div>
</AvatarProviderWrapper>
</ProjectsProvider>
);
}

View File

@@ -180,6 +180,10 @@ export interface SendMessageParams {
useAgentSearch?: boolean;
enabledToolIds?: number[];
forcedToolIds?: number[];
// Avatar query parameters
avatarId?: number;
avatarIds?: number[]; // For broadcast mode
avatarQueryMode?: string;
}
export async function* sendMessage({
@@ -203,6 +207,9 @@ export async function* sendMessage({
useAgentSearch,
enabledToolIds,
forcedToolIds,
avatarId,
avatarIds,
avatarQueryMode,
}: SendMessageParams): AsyncGenerator<PacketType, void, unknown> {
const documentsAreSelected =
selectedDocumentIds && selectedDocumentIds.length > 0;
@@ -244,6 +251,10 @@ export async function* sendMessage({
use_agentic_search: useAgentSearch ?? false,
allowed_tool_ids: enabledToolIds,
forced_tool_ids: forcedToolIds,
// Avatar query parameters
avatar_id: avatarId,
avatar_ids: avatarIds,
avatar_query_mode: avatarQueryMode,
};
const body = JSON.stringify(payload);

View File

@@ -597,3 +597,122 @@ export interface IndexingStatusRequest {
source?: ValidSources;
get_all_connectors?: boolean;
}
// ============================================================================
// Avatar Types
// ============================================================================
export enum AvatarQueryMode {
OWNED_DOCUMENTS = "owned_documents",
ACCESSIBLE_DOCUMENTS = "accessible_documents",
}
export enum AvatarPermissionRequestStatus {
PENDING = "pending",
PROCESSING = "processing", // Query running in background
APPROVED = "approved",
DENIED = "denied",
EXPIRED = "expired",
NO_ANSWER = "no_answer",
}
export interface Avatar {
id: number;
user_id: string;
user_email: string;
name: string | null;
description: string | null;
is_enabled: boolean;
default_query_mode: AvatarQueryMode;
allow_accessible_mode: boolean;
show_query_in_request: boolean;
max_requests_per_day: number | null;
created_at: string;
}
export interface AvatarListItem {
id: number;
user_id: string;
user_email: string;
name: string | null;
description: string | null;
default_query_mode: AvatarQueryMode;
allow_accessible_mode: boolean;
}
export interface AvatarUpdateRequest {
name?: string | null;
description?: string | null;
is_enabled?: boolean;
default_query_mode?: AvatarQueryMode;
allow_accessible_mode?: boolean;
show_query_in_request?: boolean;
max_requests_per_day?: number | null;
auto_approve_rules?: AutoApproveRules | null;
}
export interface AutoApproveRules {
user_ids: string[];
group_ids: number[];
all_users: boolean;
}
export interface AvatarQueryRequest {
query: string;
query_mode?: AvatarQueryMode;
chat_session_id?: string | null;
}
export interface AvatarQueryResponse {
status:
| "success"
| "processing" // Query running in background (All mode)
| "pending_permission" // Query done, awaiting owner approval
| "no_results"
| "rate_limited"
| "disabled"
| "error";
answer?: string | null;
permission_request_id?: number | null;
source_document_ids?: string[] | null;
message?: string | null;
}
export interface BroadcastQueryRequest {
avatar_ids: number[];
query: string;
query_mode?: AvatarQueryMode;
}
export interface BroadcastQueryResponse {
results: Record<number, AvatarQueryResponse>;
}
export interface PermissionRequest {
id: number;
avatar_id: number;
avatar_user_email: string;
requester_id: string;
requester_email: string;
query_text: string | null;
status: AvatarPermissionRequestStatus;
task_id: string | null; // Celery task ID for PROCESSING status
denial_reason: string | null;
created_at: string;
expires_at: string;
resolved_at: string | null;
// Only included for approved requests when the user is the requester
cached_answer: string | null;
cached_search_doc_ids: number[] | null;
}
export interface PermissionRequestDenyRequest {
denial_reason?: string | null;
}
export interface PermissionRequestApproveResponse {
request_id: number;
status: AvatarPermissionRequestStatus;
answer: string | null;
source_document_ids: number[] | null;
}

View File

@@ -64,6 +64,7 @@ import useAppFocus from "@/hooks/useAppFocus";
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
import useScreenSize from "@/hooks/useScreenSize";
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
import AvatarSection from "@/sections/sidebar/AvatarSection";
// Visible-agents = pinned-agents + current-agent (if current-agent not in pinned-agents)
// OR Visible-agents = pinned-agents (if current-agent in pinned-agents)
@@ -512,6 +513,9 @@ function AppSidebarInner({ folded, onFoldClick }: AppSidebarInnerProps) {
</SidebarSection>
</DndContext>
{/* Avatars */}
<AvatarSection folded={folded} />
{/* Wrap Projects and Recents in a shared DndContext for chat-to-project drag */}
<DndContext
sensors={sensors}

View File

@@ -0,0 +1,323 @@
"use client";
import React, { useState, useMemo } from "react";
import Link from "next/link";
import { AvatarListItem } from "@/lib/types";
import {
useQueryableAvatars,
useIncomingPermissionRequests,
} from "@/lib/avatar";
import { useAvatarContextOptional } from "@/app/chat/avatars/AvatarContext";
import SidebarSection from "@/sections/sidebar/SidebarSection";
import SidebarTab from "@/refresh-components/buttons/SidebarTab";
import SvgUsers from "@/icons/users";
import SvgX from "@/icons/x";
import { Search, User, Loader2, Radio, Bell, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import IconButton from "@/refresh-components/buttons/IconButton";
interface AvatarSectionProps {
folded?: boolean;
}
const MAX_AVATARS_TO_SHOW = 6;
export default function AvatarSection({ folded }: AvatarSectionProps) {
const avatarContext = useAvatarContextOptional();
const { avatars, loading, error } = useQueryableAvatars();
const { requests: incomingRequests } = useIncomingPermissionRequests();
const [searchExpanded, setSearchExpanded] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
// If context is not available (provider not mounted), don't render
if (!avatarContext) {
return null;
}
const pendingRequestCount = incomingRequests.length;
const {
isAvatarMode,
isBroadcastMode,
selectedAvatar,
selectedAvatars,
enableAvatarMode,
disableAvatarMode,
enableBroadcastMode,
} = avatarContext;
const filteredAvatars = useMemo(() => {
if (!searchTerm) return avatars.slice(0, MAX_AVATARS_TO_SHOW); // Show first MAX_AVATARS_TO_SHOW by default
return avatars.filter(
(avatar) =>
avatar.user_email.toLowerCase().includes(searchTerm.toLowerCase()) ||
(avatar.name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false)
);
}, [avatars, searchTerm]);
const handleAvatarClick = (avatar: AvatarListItem) => {
// Toggle selection for single avatar mode
if (selectedAvatar?.id === avatar.id) {
disableAvatarMode();
} else {
enableAvatarMode(avatar);
}
setSearchExpanded(false);
setSearchTerm("");
};
const handleEnableBroadcastMode = () => {
// Enable broadcast mode with all avatars automatically selected
enableBroadcastMode(avatars);
};
if (folded) {
return (
<SidebarTab
leftIcon={SvgUsers}
folded
active={isAvatarMode}
onClick={() => {
if (isAvatarMode) {
disableAvatarMode();
}
}}
>
Avatars
</SidebarTab>
);
}
return (
<SidebarSection
title="Avatars"
action={
<div className="flex items-center gap-1">
{/* Pending requests indicator */}
<Link href="/avatars?tab=requests&subtab=incoming">
<div className="relative">
<IconButton
icon={Bell}
internal
tooltip={
pendingRequestCount > 0
? `${pendingRequestCount} pending request${
pendingRequestCount !== 1 ? "s" : ""
}`
: "No pending requests"
}
/>
{pendingRequestCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-error text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{pendingRequestCount > 9 ? "9+" : pendingRequestCount}
</span>
)}
</div>
</Link>
<Link href="/avatars?tab=settings">
<IconButton icon={Settings} internal tooltip="Avatar Settings" />
</Link>
<IconButton
icon={searchExpanded ? SvgX : Search}
internal
tooltip={searchExpanded ? "Close Search" : "Search Avatars"}
onClick={() => {
setSearchExpanded(!searchExpanded);
if (!searchExpanded) setSearchTerm("");
}}
/>
</div>
}
>
{/* Active avatar indicator - Single mode */}
{isAvatarMode && !isBroadcastMode && selectedAvatar && (
<div className="mx-2 mb-2 p-2 bg-accent/10 border border-accent/30 rounded-08">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<User className="h-3 w-3 text-accent" />
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-accent truncate">
Querying: {selectedAvatar.name || selectedAvatar.user_email}
</div>
</div>
</div>
<IconButton
icon={SvgX}
internal
tooltip="Exit Avatar Mode"
onClick={disableAvatarMode}
/>
</div>
</div>
)}
{/* Active avatar indicator - Broadcast mode */}
{isAvatarMode && isBroadcastMode && (
<div className="mx-2 mb-2 p-2 bg-accent/10 border border-accent/30 rounded-08">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Radio className="h-3 w-3 text-accent" />
<span className="text-xs font-medium text-accent">
Broadcast Mode
</span>
</div>
<IconButton
icon={SvgX}
internal
tooltip="Exit Avatar Mode"
onClick={disableAvatarMode}
/>
</div>
<div className="text-[10px] text-accent/80">
{selectedAvatars.length === 0
? "Select avatars to query"
: `${selectedAvatars.length} avatar${
selectedAvatars.length !== 1 ? "s" : ""
} selected`}
</div>
{selectedAvatars.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{selectedAvatars.slice(0, 3).map((avatar) => (
<span
key={avatar.id}
className="text-[10px] bg-accent/20 px-1.5 py-0.5 rounded text-accent"
>
{avatar.name || avatar.user_email.split("@")[0]}
</span>
))}
{selectedAvatars.length > 3 && (
<span className="text-[10px] text-accent/60">
+{selectedAvatars.length - 3} more
</span>
)}
</div>
)}
</div>
)}
{/* Search input */}
{searchExpanded && (
<div className="mx-2 mb-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-text-subtle" />
<input
type="text"
placeholder="Search avatars..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-7 pr-2 py-1.5 text-xs border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-accent"
autoFocus
/>
</div>
</div>
)}
{/* Broadcast (Everyone) option - always show at top when not in broadcast mode */}
{!loading && avatars.length > 0 && !isBroadcastMode && (
<div
onClick={handleEnableBroadcastMode}
className="flex items-center gap-2 px-3 py-2 mx-1 mb-1 rounded-08 cursor-pointer hover:bg-accent/10 text-text-03 hover:text-accent border-b border-border transition-colors"
>
<div className="w-6 h-6 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0">
<Radio className="h-3 w-3 text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">Broadcast</div>
<div className="text-[10px] text-text-02">
Ask everyone at the company at once.
</div>
</div>
</div>
)}
{/* Loading state */}
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-text-subtle" />
</div>
)}
{/* Error state */}
{error && <div className="mx-2 py-2 text-xs text-error">{error}</div>}
{/* Avatar list */}
{!loading && !error && (
<>
{filteredAvatars.length === 0 ? (
<div className="mx-2 py-2 text-xs text-text-subtle">
{searchTerm
? "No avatars match your search"
: "No avatars available"}
</div>
) : (
// Don't show individual avatars in broadcast mode (everyone is selected)
!isBroadcastMode &&
filteredAvatars.map((avatar) => (
<AvatarRow
key={avatar.id}
avatar={avatar}
isSelected={selectedAvatar?.id === avatar.id}
onClick={() => handleAvatarClick(avatar)}
/>
))
)}
{/* Show more if there are more avatars - only in single mode */}
{!isBroadcastMode &&
!searchTerm &&
avatars.length > MAX_AVATARS_TO_SHOW && (
<div
className="text-xs text-text-03 hover:text-text-04 cursor-pointer ml-4 mt-3"
onClick={() => setSearchExpanded(true)}
>
{avatars.length - MAX_AVATARS_TO_SHOW} more avatars
</div>
)}
</>
)}
</SidebarSection>
);
}
interface AvatarRowProps {
avatar: AvatarListItem;
isSelected: boolean;
onClick: () => void;
}
function AvatarRow({ avatar, isSelected, onClick }: AvatarRowProps) {
return (
<div
onClick={onClick}
className={cn(
"flex items-center gap-2 px-3 py-1.5 mx-1 rounded-08 cursor-pointer transition-colors",
isSelected
? "bg-accent/10 text-accent"
: "hover:bg-background-tint-03 text-text-03 hover:text-text-04"
)}
>
<div
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0",
isSelected ? "bg-accent/20" : "bg-background-tint-03"
)}
>
<User
className={cn("h-3 w-3", isSelected ? "text-accent" : "text-text-03")}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{avatar.name || avatar.user_email}
</div>
{avatar.name && (
<div className="text-[10px] text-text-02 truncate">
{avatar.user_email}
</div>
)}
</div>
</div>
);
}