mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-01 21:55:46 +00:00
Compare commits
4 Commits
v2.12.1
...
hackathon/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98bd71a796 | ||
|
|
f3462414b7 | ||
|
|
0897e57d2d | ||
|
|
5a4c2bb263 |
@@ -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")
|
||||
236
backend/alembic/versions/a1b2c3d4e5f6_add_avatar_tables.py
Normal file
236
backend/alembic/versions/a1b2c3d4e5f6_add_avatar_tables.py
Normal 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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -98,5 +98,6 @@ for bootstep in base_bootsteps:
|
||||
celery_app.autodiscover_tasks(
|
||||
[
|
||||
"onyx.background.celery.tasks.pruning",
|
||||
"onyx.background.celery.tasks.avatar",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
294
backend/onyx/background/celery/tasks/avatar/tasks.py
Normal file
294
backend/onyx/background/celery/tasks/avatar/tasks.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
449
backend/onyx/db/avatar.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
backend/onyx/server/features/avatar/__init__.py
Normal file
0
backend/onyx/server/features/avatar/__init__.py
Normal file
104
backend/onyx/server/features/avatar/api.py
Normal file
104
backend/onyx/server/features/avatar/api.py
Normal 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)
|
||||
217
backend/onyx/server/features/avatar/models.py
Normal file
217
backend/onyx/server/features/avatar/models.py
Normal 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
|
||||
216
backend/onyx/server/features/avatar/permission_api.py
Normal file
216
backend/onyx/server/features/avatar/permission_api.py
Normal 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)
|
||||
96
backend/onyx/server/features/avatar/query_api.py
Normal file
96
backend/onyx/server/features/avatar/query_api.py
Normal 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)
|
||||
415
backend/onyx/server/features/avatar/query_service.py
Normal file
415
backend/onyx/server/features/avatar/query_service.py
Normal 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
|
||||
@@ -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."
|
||||
|
||||
277
web/src/app/avatars/components/AvatarQueryPanel.tsx
Normal file
277
web/src/app/avatars/components/AvatarQueryPanel.tsx
Normal 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;
|
||||
137
web/src/app/avatars/components/AvatarSelector.tsx
Normal file
137
web/src/app/avatars/components/AvatarSelector.tsx
Normal 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;
|
||||
277
web/src/app/avatars/components/AvatarSettings.tsx
Normal file
277
web/src/app/avatars/components/AvatarSettings.tsx
Normal 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;
|
||||
432
web/src/app/avatars/components/PermissionRequests.tsx
Normal file
432
web/src/app/avatars/components/PermissionRequests.tsx
Normal 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;
|
||||
97
web/src/app/avatars/page.tsx
Normal file
97
web/src/app/avatars/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
web/src/app/chat/avatars/AvatarContext.tsx
Normal file
151
web/src/app/chat/avatars/AvatarContext.tsx
Normal 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);
|
||||
}
|
||||
12
web/src/app/chat/avatars/AvatarProviderWrapper.tsx
Normal file
12
web/src/app/chat/avatars/AvatarProviderWrapper.tsx
Normal 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>;
|
||||
}
|
||||
6
web/src/app/chat/avatars/index.ts
Normal file
6
web/src/app/chat/avatars/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
AvatarProvider,
|
||||
useAvatarContext,
|
||||
useAvatarContextOptional,
|
||||
} from "./AvatarContext";
|
||||
export { default as AvatarProviderWrapper } from "./AvatarProviderWrapper";
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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={{
|
||||
|
||||
114
web/src/app/chat/components/avatar/AvatarModeIndicator.tsx
Normal file
114
web/src/app/chat/components/avatar/AvatarModeIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
web/src/app/chat/components/avatar/AvatarQueryResult.tsx
Normal file
220
web/src/app/chat/components/avatar/AvatarQueryResult.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
web/src/app/chat/components/avatar/ChatSessionAvatarRequests.tsx
Normal file
140
web/src/app/chat/components/avatar/ChatSessionAvatarRequests.tsx
Normal 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;
|
||||
2
web/src/app/chat/components/avatar/index.ts
Normal file
2
web/src/app/chat/components/avatar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AvatarModeIndicator } from "./AvatarModeIndicator";
|
||||
export { AvatarQueryResult } from "./AvatarQueryResult";
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
323
web/src/sections/sidebar/AvatarSection.tsx
Normal file
323
web/src/sections/sidebar/AvatarSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user