mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-21 01:35:46 +00:00
Compare commits
20 Commits
my_docs_fe
...
server_sid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a01014212 | ||
|
|
45b6c5bfed | ||
|
|
04e980a0e8 | ||
|
|
3c2480ef21 | ||
|
|
ebb57d6216 | ||
|
|
4c230f92ea | ||
|
|
07d75b04d1 | ||
|
|
a8d10750c1 | ||
|
|
85e3ed57f1 | ||
|
|
e10cc8ccdb | ||
|
|
7018bc974b | ||
|
|
9c9075d71d | ||
|
|
338e084062 | ||
|
|
2f64031f5c | ||
|
|
abb74f2eaa | ||
|
|
a3e3d83b7e | ||
|
|
4dc88ca037 | ||
|
|
11e7e1c4d6 | ||
|
|
f2d74ce540 | ||
|
|
25389c5120 |
12
.github/workflows/nightly-scan-licenses.yml
vendored
12
.github/workflows/nightly-scan-licenses.yml
vendored
@@ -53,22 +53,26 @@ jobs:
|
||||
exclude: '(?i)^(pylint|aio[-_]*).*'
|
||||
|
||||
- name: Print report
|
||||
if: ${{ always() }}
|
||||
if: always()
|
||||
run: echo "${{ steps.license_check_report.outputs.report }}"
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: ./web
|
||||
run: npm ci
|
||||
|
||||
|
||||
# be careful enabling the sarif and upload as it may spam the security tab
|
||||
# with a huge amount of items. Work out the issues before enabling upload.
|
||||
- name: Run Trivy vulnerability scanner in repo mode
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
if: always()
|
||||
uses: aquasecurity/trivy-action@0.29.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
scanners: license
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
# format: sarif
|
||||
# output: trivy-results.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
|
||||
# - name: Upload Trivy scan results to GitHub Security tab
|
||||
# uses: github/codeql-action/upload-sarif@v3
|
||||
|
||||
84
backend/alembic/versions/3bd4c84fe72f_improved_index.py
Normal file
84
backend/alembic/versions/3bd4c84fe72f_improved_index.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""improved index
|
||||
|
||||
Revision ID: 3bd4c84fe72f
|
||||
Revises: 8f43500ee275
|
||||
Create Date: 2025-02-26 13:07:56.217791
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3bd4c84fe72f"
|
||||
down_revision = "8f43500ee275"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# NOTE:
|
||||
# This migration addresses issues with the previous migration (8f43500ee275) which caused
|
||||
# an outage by creating an index without using CONCURRENTLY. This migration:
|
||||
#
|
||||
# 1. Creates more efficient full-text search capabilities using tsvector columns and GIN indexes
|
||||
# 2. Uses CONCURRENTLY for all index creation to prevent table locking
|
||||
# 3. Explicitly manages transactions with COMMIT statements to allow CONCURRENTLY to work
|
||||
# (see: https://www.postgresql.org/docs/9.4/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY)
|
||||
# (see: https://github.com/sqlalchemy/alembic/issues/277)
|
||||
# 4. Adds indexes to both chat_message and chat_session tables for comprehensive search
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create a GIN index for full-text search on chat_message.message
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE chat_message
|
||||
ADD COLUMN message_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', message)) STORED;
|
||||
"""
|
||||
)
|
||||
|
||||
# Commit the current transaction before creating concurrent indexes
|
||||
op.execute("COMMIT")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chat_message_tsv
|
||||
ON chat_message
|
||||
USING GIN (message_tsv)
|
||||
"""
|
||||
)
|
||||
|
||||
# Also add a stored tsvector column for chat_session.description
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE chat_session
|
||||
ADD COLUMN description_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', coalesce(description, ''))) STORED;
|
||||
"""
|
||||
)
|
||||
|
||||
# Commit again before creating the second concurrent index
|
||||
op.execute("COMMIT")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chat_session_desc_tsv
|
||||
ON chat_session
|
||||
USING GIN (description_tsv)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop the indexes first (use CONCURRENTLY for dropping too)
|
||||
op.execute("COMMIT")
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_chat_message_tsv;")
|
||||
|
||||
op.execute("COMMIT")
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_chat_session_desc_tsv;")
|
||||
|
||||
# Then drop the columns
|
||||
op.execute("ALTER TABLE chat_message DROP COLUMN IF EXISTS message_tsv;")
|
||||
op.execute("ALTER TABLE chat_session DROP COLUMN IF EXISTS description_tsv;")
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS idx_chat_message_message_lower;")
|
||||
@@ -1,107 +0,0 @@
|
||||
"""add user files
|
||||
|
||||
Revision ID: 9aadf32dfeb4
|
||||
Revises: 8f43500ee275
|
||||
Create Date: 2025-01-26 16:08:21.551022
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import datetime
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "9aadf32dfeb4"
|
||||
down_revision = "8f43500ee275"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create user_folder table without parent_id
|
||||
op.create_table(
|
||||
"user_folder",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.UUID(), sa.ForeignKey("user.id"), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=True),
|
||||
sa.Column("description", sa.String(length=255), nullable=True),
|
||||
sa.Column("display_priority", sa.Integer(), nullable=True, default=0),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
)
|
||||
|
||||
# Create user_file table with folder_id instead of parent_folder_id
|
||||
op.create_table(
|
||||
"user_file",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.UUID(), sa.ForeignKey("user.id"), nullable=True),
|
||||
sa.Column(
|
||||
"folder_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("user_folder.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("token_count", sa.Integer(), nullable=True),
|
||||
sa.Column("file_type", sa.String(), nullable=True),
|
||||
sa.Column("file_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("document_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
default=datetime.datetime.utcnow,
|
||||
),
|
||||
sa.Column(
|
||||
"cc_pair_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("connector_credential_pair.id"),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Create persona__user_file table
|
||||
op.create_table(
|
||||
"persona__user_file",
|
||||
sa.Column(
|
||||
"persona_id", sa.Integer(), sa.ForeignKey("persona.id"), primary_key=True
|
||||
),
|
||||
sa.Column(
|
||||
"user_file_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("user_file.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Create persona__user_folder table
|
||||
op.create_table(
|
||||
"persona__user_folder",
|
||||
sa.Column(
|
||||
"persona_id", sa.Integer(), sa.ForeignKey("persona.id"), primary_key=True
|
||||
),
|
||||
sa.Column(
|
||||
"user_folder_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("user_folder.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"connector_credential_pair",
|
||||
sa.Column("is_user_file", sa.Boolean(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop the persona__user_folder table
|
||||
op.drop_table("persona__user_folder")
|
||||
# Drop the persona__user_file table
|
||||
op.drop_table("persona__user_file")
|
||||
# Drop the user_file table
|
||||
op.drop_table("user_file")
|
||||
# Drop the user_folder table
|
||||
op.drop_table("user_folder")
|
||||
op.drop_column("connector_credential_pair", "is_user_file")
|
||||
@@ -2,6 +2,7 @@ import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from http import HTTPStatus
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -21,8 +22,10 @@ from ee.onyx.server.query_history.models import QuestionAnswerPairSnapshot
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.auth.users import get_display_email
|
||||
from onyx.chat.chat_utils import create_chat_chain
|
||||
from onyx.configs.app_configs import ONYX_QUERY_HISTORY_TYPE
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.configs.constants import QAFeedbackType
|
||||
from onyx.configs.constants import QueryHistoryType
|
||||
from onyx.configs.constants import SessionType
|
||||
from onyx.db.chat import get_chat_session_by_id
|
||||
from onyx.db.chat import get_chat_sessions_by_user
|
||||
@@ -35,6 +38,8 @@ from onyx.server.query_and_chat.models import ChatSessionsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ONYX_ANONYMIZED_EMAIL = "anonymous@anonymous.invalid"
|
||||
|
||||
|
||||
def fetch_and_process_chat_session_history(
|
||||
db_session: Session,
|
||||
@@ -107,6 +112,17 @@ def get_user_chat_sessions(
|
||||
_: User | None = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ChatSessionsResponse:
|
||||
# we specifically don't allow this endpoint if "anonymized" since
|
||||
# this is a direct query on the user id
|
||||
if ONYX_QUERY_HISTORY_TYPE in [
|
||||
QueryHistoryType.DISABLED,
|
||||
QueryHistoryType.ANONYMIZED,
|
||||
]:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Per user query history has been disabled by the administrator.",
|
||||
)
|
||||
|
||||
try:
|
||||
chat_sessions = get_chat_sessions_by_user(
|
||||
user_id=user_id, deleted=False, db_session=db_session, limit=0
|
||||
@@ -122,6 +138,7 @@ def get_user_chat_sessions(
|
||||
name=chat.description,
|
||||
persona_id=chat.persona_id,
|
||||
time_created=chat.time_created.isoformat(),
|
||||
time_updated=chat.time_updated.isoformat(),
|
||||
shared_status=chat.shared_status,
|
||||
folder_id=chat.folder_id,
|
||||
current_alternate_model=chat.current_alternate_model,
|
||||
@@ -141,6 +158,12 @@ def get_chat_session_history(
|
||||
_: User | None = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> PaginatedReturn[ChatSessionMinimal]:
|
||||
if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.DISABLED:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Query history has been disabled by the administrator.",
|
||||
)
|
||||
|
||||
page_of_chat_sessions = get_page_of_chat_sessions(
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
@@ -157,11 +180,16 @@ def get_chat_session_history(
|
||||
feedback_filter=feedback_type,
|
||||
)
|
||||
|
||||
minimal_chat_sessions: list[ChatSessionMinimal] = []
|
||||
|
||||
for chat_session in page_of_chat_sessions:
|
||||
minimal_chat_session = ChatSessionMinimal.from_chat_session(chat_session)
|
||||
if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.ANONYMIZED:
|
||||
minimal_chat_session.user_email = ONYX_ANONYMIZED_EMAIL
|
||||
minimal_chat_sessions.append(minimal_chat_session)
|
||||
|
||||
return PaginatedReturn(
|
||||
items=[
|
||||
ChatSessionMinimal.from_chat_session(chat_session)
|
||||
for chat_session in page_of_chat_sessions
|
||||
],
|
||||
items=minimal_chat_sessions,
|
||||
total_items=total_filtered_chat_sessions_count,
|
||||
)
|
||||
|
||||
@@ -172,6 +200,12 @@ def get_chat_session_admin(
|
||||
_: User | None = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ChatSessionSnapshot:
|
||||
if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.DISABLED:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Query history has been disabled by the administrator.",
|
||||
)
|
||||
|
||||
try:
|
||||
chat_session = get_chat_session_by_id(
|
||||
chat_session_id=chat_session_id,
|
||||
@@ -193,6 +227,9 @@ def get_chat_session_admin(
|
||||
f"Could not create snapshot for chat session with id '{chat_session_id}'",
|
||||
)
|
||||
|
||||
if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.ANONYMIZED:
|
||||
snapshot.user_email = ONYX_ANONYMIZED_EMAIL
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
@@ -203,6 +240,12 @@ def get_query_history_as_csv(
|
||||
end: datetime | None = None,
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StreamingResponse:
|
||||
if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.DISABLED:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Query history has been disabled by the administrator.",
|
||||
)
|
||||
|
||||
complete_chat_session_history = fetch_and_process_chat_session_history(
|
||||
db_session=db_session,
|
||||
start=start or datetime.fromtimestamp(0, tz=timezone.utc),
|
||||
@@ -213,6 +256,9 @@ def get_query_history_as_csv(
|
||||
|
||||
question_answer_pairs: list[QuestionAnswerPairSnapshot] = []
|
||||
for chat_session_snapshot in complete_chat_session_history:
|
||||
if ONYX_QUERY_HISTORY_TYPE == QueryHistoryType.ANONYMIZED:
|
||||
chat_session_snapshot.user_email = ONYX_ANONYMIZED_EMAIL
|
||||
|
||||
question_answer_pairs.extend(
|
||||
QuestionAnswerPairSnapshot.from_chat_session_snapshot(chat_session_snapshot)
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from ee.onyx.configs.app_configs import STRIPE_PRICE_ID
|
||||
from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY
|
||||
from ee.onyx.server.tenants.access import generate_data_plane_token
|
||||
from ee.onyx.server.tenants.models import BillingInformation
|
||||
from ee.onyx.server.tenants.models import SubscriptionStatusResponse
|
||||
from onyx.configs.app_configs import CONTROL_PLANE_API_BASE_URL
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
@@ -41,7 +42,9 @@ def fetch_tenant_stripe_information(tenant_id: str) -> dict:
|
||||
return response.json()
|
||||
|
||||
|
||||
def fetch_billing_information(tenant_id: str) -> BillingInformation:
|
||||
def fetch_billing_information(
|
||||
tenant_id: str,
|
||||
) -> BillingInformation | SubscriptionStatusResponse:
|
||||
logger.info("Fetching billing information")
|
||||
token = generate_data_plane_token()
|
||||
headers = {
|
||||
@@ -52,8 +55,19 @@ def fetch_billing_information(tenant_id: str) -> BillingInformation:
|
||||
params = {"tenant_id": tenant_id}
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
billing_info = BillingInformation(**response.json())
|
||||
return billing_info
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
# Check if the response indicates no subscription
|
||||
if (
|
||||
isinstance(response_data, dict)
|
||||
and "subscribed" in response_data
|
||||
and not response_data["subscribed"]
|
||||
):
|
||||
return SubscriptionStatusResponse(**response_data)
|
||||
|
||||
# Otherwise, parse as BillingInformation
|
||||
return BillingInformation(**response_data)
|
||||
|
||||
|
||||
def register_tenant_users(tenant_id: str, number_of_users: int) -> stripe.Subscription:
|
||||
|
||||
@@ -200,25 +200,6 @@ async def rollback_tenant_provisioning(tenant_id: str) -> None:
|
||||
|
||||
|
||||
def configure_default_api_keys(db_session: Session) -> None:
|
||||
if OPENAI_DEFAULT_API_KEY:
|
||||
open_provider = LLMProviderUpsertRequest(
|
||||
name="OpenAI",
|
||||
provider=OPENAI_PROVIDER_NAME,
|
||||
api_key=OPENAI_DEFAULT_API_KEY,
|
||||
default_model_name="gpt-4",
|
||||
fast_default_model_name="gpt-4o-mini",
|
||||
model_names=OPEN_AI_MODEL_NAMES,
|
||||
)
|
||||
try:
|
||||
full_provider = upsert_llm_provider(open_provider, db_session)
|
||||
update_default_provider(full_provider.id, db_session)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure OpenAI provider: {e}")
|
||||
else:
|
||||
logger.error(
|
||||
"OPENAI_DEFAULT_API_KEY not set, skipping OpenAI provider configuration"
|
||||
)
|
||||
|
||||
if ANTHROPIC_DEFAULT_API_KEY:
|
||||
anthropic_provider = LLMProviderUpsertRequest(
|
||||
name="Anthropic",
|
||||
@@ -227,6 +208,7 @@ def configure_default_api_keys(db_session: Session) -> None:
|
||||
default_model_name="claude-3-7-sonnet-20250219",
|
||||
fast_default_model_name="claude-3-5-sonnet-20241022",
|
||||
model_names=ANTHROPIC_MODEL_NAMES,
|
||||
display_model_names=["claude-3-5-sonnet-20241022"],
|
||||
)
|
||||
try:
|
||||
full_provider = upsert_llm_provider(anthropic_provider, db_session)
|
||||
@@ -238,6 +220,26 @@ def configure_default_api_keys(db_session: Session) -> None:
|
||||
"ANTHROPIC_DEFAULT_API_KEY not set, skipping Anthropic provider configuration"
|
||||
)
|
||||
|
||||
if OPENAI_DEFAULT_API_KEY:
|
||||
open_provider = LLMProviderUpsertRequest(
|
||||
name="OpenAI",
|
||||
provider=OPENAI_PROVIDER_NAME,
|
||||
api_key=OPENAI_DEFAULT_API_KEY,
|
||||
default_model_name="gpt-4o",
|
||||
fast_default_model_name="gpt-4o-mini",
|
||||
model_names=OPEN_AI_MODEL_NAMES,
|
||||
display_model_names=["o1", "o3-mini", "gpt-4o", "gpt-4o-mini"],
|
||||
)
|
||||
try:
|
||||
full_provider = upsert_llm_provider(open_provider, db_session)
|
||||
update_default_provider(full_provider.id, db_session)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure OpenAI provider: {e}")
|
||||
else:
|
||||
logger.error(
|
||||
"OPENAI_DEFAULT_API_KEY not set, skipping OpenAI provider configuration"
|
||||
)
|
||||
|
||||
if COHERE_DEFAULT_API_KEY:
|
||||
cloud_embedding_provider = CloudEmbeddingProviderCreationRequest(
|
||||
provider_type=EmbeddingProvider.COHERE,
|
||||
|
||||
@@ -319,10 +319,8 @@ def dispatch_separated(
|
||||
sep: str = DISPATCH_SEP_CHAR,
|
||||
) -> list[BaseMessage_Content]:
|
||||
num = 1
|
||||
accumulated_tokens = ""
|
||||
streamed_tokens: list[BaseMessage_Content] = []
|
||||
for token in tokens:
|
||||
accumulated_tokens += cast(str, token.content)
|
||||
content = cast(str, token.content)
|
||||
if sep in content:
|
||||
sub_question_parts = content.split(sep)
|
||||
|
||||
@@ -411,7 +411,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
user: User
|
||||
user: User | None = None
|
||||
|
||||
try:
|
||||
# Attempt to get user by OAuth account
|
||||
@@ -420,15 +420,20 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
except exceptions.UserNotExists:
|
||||
try:
|
||||
# Attempt to get user by email
|
||||
user = await self.get_by_email(account_email)
|
||||
user = await self.user_db.get_by_email(account_email)
|
||||
if not associate_by_email:
|
||||
raise exceptions.UserAlreadyExists()
|
||||
|
||||
user = await self.user_db.add_oauth_account(
|
||||
user, oauth_account_dict
|
||||
)
|
||||
# Make sure user is not None before adding OAuth account
|
||||
if user is not None:
|
||||
user = await self.user_db.add_oauth_account(
|
||||
user, oauth_account_dict
|
||||
)
|
||||
else:
|
||||
# This shouldn't happen since get_by_email would raise UserNotExists
|
||||
# but adding as a safeguard
|
||||
raise exceptions.UserNotExists()
|
||||
|
||||
# If user not found by OAuth account or email, create a new user
|
||||
except exceptions.UserNotExists:
|
||||
password = self.password_helper.generate()
|
||||
user_dict = {
|
||||
@@ -439,26 +444,36 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
|
||||
user = await self.user_db.create(user_dict)
|
||||
|
||||
# Explicitly set the Postgres schema for this session to ensure
|
||||
# OAuth account creation happens in the correct tenant schema
|
||||
|
||||
# Add OAuth account
|
||||
await self.user_db.add_oauth_account(user, oauth_account_dict)
|
||||
await self.on_after_register(user, request)
|
||||
# Add OAuth account only if user creation was successful
|
||||
if user is not None:
|
||||
await self.user_db.add_oauth_account(user, oauth_account_dict)
|
||||
await self.on_after_register(user, request)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create user account"
|
||||
)
|
||||
|
||||
else:
|
||||
for existing_oauth_account in user.oauth_accounts:
|
||||
if (
|
||||
existing_oauth_account.account_id == account_id
|
||||
and existing_oauth_account.oauth_name == oauth_name
|
||||
):
|
||||
user = await self.user_db.update_oauth_account(
|
||||
user,
|
||||
# NOTE: OAuthAccount DOES implement the OAuthAccountProtocol
|
||||
# but the type checker doesn't know that :(
|
||||
existing_oauth_account, # type: ignore
|
||||
oauth_account_dict,
|
||||
)
|
||||
# User exists, update OAuth account if needed
|
||||
if user is not None: # Add explicit check
|
||||
for existing_oauth_account in user.oauth_accounts:
|
||||
if (
|
||||
existing_oauth_account.account_id == account_id
|
||||
and existing_oauth_account.oauth_name == oauth_name
|
||||
):
|
||||
user = await self.user_db.update_oauth_account(
|
||||
user,
|
||||
# NOTE: OAuthAccount DOES implement the OAuthAccountProtocol
|
||||
# but the type checker doesn't know that :(
|
||||
existing_oauth_account, # type: ignore
|
||||
oauth_account_dict,
|
||||
)
|
||||
|
||||
# Ensure user is not None before proceeding
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to authenticate or create user"
|
||||
)
|
||||
|
||||
# NOTE: Most IdPs have very short expiry times, and we don't want to force the user to
|
||||
# re-authenticate that frequently, so by default this is disabled
|
||||
|
||||
@@ -298,6 +298,7 @@ def cloud_beat_task_generator(
|
||||
|
||||
last_lock_time = time.monotonic()
|
||||
tenant_ids: list[str] = []
|
||||
num_processed_tenants = 0
|
||||
|
||||
try:
|
||||
tenant_ids = get_all_tenant_ids()
|
||||
@@ -325,6 +326,8 @@ def cloud_beat_task_generator(
|
||||
expires=expires,
|
||||
ignore_result=True,
|
||||
)
|
||||
|
||||
num_processed_tenants += 1
|
||||
except SoftTimeLimitExceeded:
|
||||
task_logger.info(
|
||||
"Soft time limit exceeded, task is being terminated gracefully."
|
||||
@@ -344,6 +347,7 @@ def cloud_beat_task_generator(
|
||||
task_logger.info(
|
||||
f"cloud_beat_task_generator finished: "
|
||||
f"task={task_name} "
|
||||
f"num_processed_tenants={num_processed_tenants} "
|
||||
f"num_tenants={len(tenant_ids)} "
|
||||
f"elapsed={time_elapsed:.2f}"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Optional
|
||||
|
||||
from onyx.configs.constants import POSTGRES_CELERY_WORKER_INDEXING_CHILD_APP_NAME
|
||||
from onyx.db.engine import SqlEngine
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.setup import setup_logger
|
||||
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
|
||||
from shared_configs.configs import TENANT_ID_PREFIX
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
|
||||
@@ -86,7 +86,6 @@ from onyx.document_index.factory import get_default_document_index
|
||||
from onyx.file_store.models import ChatFileType
|
||||
from onyx.file_store.models import FileDescriptor
|
||||
from onyx.file_store.utils import load_all_chat_files
|
||||
from onyx.file_store.utils import load_all_user_files
|
||||
from onyx.file_store.utils import save_files
|
||||
from onyx.llm.exceptions import GenAIDisabledException
|
||||
from onyx.llm.factory import get_llms_for_persona
|
||||
@@ -263,11 +262,8 @@ def _get_force_search_settings(
|
||||
search_tool_available = any(isinstance(tool, SearchTool) for tool in tools)
|
||||
|
||||
if not internet_search_available and not search_tool_available:
|
||||
if new_msg_req.force_user_file_search:
|
||||
return ForceUseTool(force_use=True, tool_name=SearchTool._NAME)
|
||||
else:
|
||||
# Does not matter much which tool is set here as force is false and neither tool is available
|
||||
return ForceUseTool(force_use=False, tool_name=SearchTool._NAME)
|
||||
# Does not matter much which tool is set here as force is false and neither tool is available
|
||||
return ForceUseTool(force_use=False, tool_name=SearchTool._NAME)
|
||||
|
||||
tool_name = SearchTool._NAME if search_tool_available else InternetSearchTool._NAME
|
||||
# Currently, the internet search tool does not support query override
|
||||
@@ -283,7 +279,6 @@ def _get_force_search_settings(
|
||||
|
||||
should_force_search = any(
|
||||
[
|
||||
new_msg_req.force_user_file_search,
|
||||
new_msg_req.retrieval_options
|
||||
and new_msg_req.retrieval_options.run_search
|
||||
== OptionalSearchSetting.ALWAYS,
|
||||
@@ -543,15 +538,6 @@ def stream_chat_message_objects(
|
||||
req_file_ids = [f["id"] for f in new_msg_req.file_descriptors]
|
||||
latest_query_files = [file for file in files if file.file_id in req_file_ids]
|
||||
|
||||
if not new_msg_req.force_user_file_search:
|
||||
user_files = load_all_user_files(
|
||||
new_msg_req.user_file_ids,
|
||||
new_msg_req.user_folder_ids,
|
||||
db_session,
|
||||
)
|
||||
|
||||
latest_query_files += user_files
|
||||
|
||||
if user_message:
|
||||
attach_files_to_chat_message(
|
||||
chat_message=user_message,
|
||||
@@ -695,7 +681,6 @@ def stream_chat_message_objects(
|
||||
user=user,
|
||||
llm=llm,
|
||||
fast_llm=fast_llm,
|
||||
use_file_search=new_msg_req.force_user_file_search,
|
||||
search_tool_config=SearchToolConfig(
|
||||
answer_style_config=answer_style_config,
|
||||
document_pruning_config=document_pruning_config,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import cast
|
||||
from onyx.auth.schemas import AuthBackend
|
||||
from onyx.configs.constants import AuthType
|
||||
from onyx.configs.constants import DocumentIndexType
|
||||
from onyx.configs.constants import QueryHistoryType
|
||||
from onyx.file_processing.enums import HtmlBasedConnectorTransformLinksStrategy
|
||||
|
||||
#####
|
||||
@@ -29,6 +30,9 @@ GENERATIVE_MODEL_ACCESS_CHECK_FREQ = int(
|
||||
) # 1 day
|
||||
DISABLE_GENERATIVE_AI = os.environ.get("DISABLE_GENERATIVE_AI", "").lower() == "true"
|
||||
|
||||
ONYX_QUERY_HISTORY_TYPE = QueryHistoryType(
|
||||
(os.environ.get("ONYX_QUERY_HISTORY_TYPE") or QueryHistoryType.NORMAL.value).lower()
|
||||
)
|
||||
|
||||
#####
|
||||
# Web Configs
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
INPUT_PROMPT_YAML = "./onyx/seeding/input_prompts.yaml"
|
||||
PROMPTS_YAML = "./onyx/seeding/prompts.yaml"
|
||||
PERSONAS_YAML = "./onyx/seeding/personas.yaml"
|
||||
USER_FOLDERS_YAML = "./onyx/seeding/user_folders.yaml"
|
||||
|
||||
NUM_RETURNED_HITS = 50
|
||||
# Used for LLM filtering and reranking
|
||||
# We want this to be approximately the number of results we want to show on the first page
|
||||
|
||||
@@ -213,6 +213,12 @@ class AuthType(str, Enum):
|
||||
CLOUD = "cloud"
|
||||
|
||||
|
||||
class QueryHistoryType(str, Enum):
|
||||
DISABLED = "disabled"
|
||||
ANONYMIZED = "anonymized"
|
||||
NORMAL = "normal"
|
||||
|
||||
|
||||
# Special characters for password validation
|
||||
PASSWORD_SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ class BaseFilters(BaseModel):
|
||||
document_set: list[str] | None = None
|
||||
time_cutoff: datetime | None = None
|
||||
tags: list[Tag] | None = None
|
||||
user_file_ids: list[int] | None = None
|
||||
|
||||
|
||||
class IndexFilters(BaseFilters):
|
||||
|
||||
@@ -160,16 +160,7 @@ def retrieval_preprocessing(
|
||||
user_acl_filters = (
|
||||
None if bypass_acl else build_access_filters_for_user(user, db_session)
|
||||
)
|
||||
user_file_ids = preset_filters.user_file_ids
|
||||
if persona and persona.user_files:
|
||||
user_file_ids = user_file_ids + [
|
||||
file.id
|
||||
for file in persona.user_files
|
||||
if file.id not in preset_filters.user_file_ids
|
||||
]
|
||||
|
||||
final_filters = IndexFilters(
|
||||
user_file_ids=user_file_ids,
|
||||
source_type=preset_filters.source_type or predicted_source_filters,
|
||||
document_set=preset_filters.document_set,
|
||||
time_cutoff=time_filter or predicted_time_cutoff,
|
||||
|
||||
@@ -168,7 +168,7 @@ def get_chat_sessions_by_user(
|
||||
if not include_onyxbot_flows:
|
||||
stmt = stmt.where(ChatSession.onyxbot_flow.is_(False))
|
||||
|
||||
stmt = stmt.order_by(desc(ChatSession.time_created))
|
||||
stmt = stmt.order_by(desc(ChatSession.time_updated))
|
||||
|
||||
if deleted is not None:
|
||||
stmt = stmt.where(ChatSession.deleted == deleted)
|
||||
@@ -962,6 +962,7 @@ def translate_db_message_to_chat_message_detail(
|
||||
chat_message.sub_questions
|
||||
),
|
||||
refined_answer_improvement=chat_message.refined_answer_improvement,
|
||||
is_agentic=chat_message.is_agentic,
|
||||
error=chat_message.error,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ from typing import Optional
|
||||
from typing import Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import column
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import literal
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import union_all
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.expression import ColumnClause
|
||||
|
||||
from onyx.db.models import ChatMessage
|
||||
from onyx.db.models import ChatSession
|
||||
@@ -26,127 +25,87 @@ def search_chat_sessions(
|
||||
include_onyxbot_flows: bool = False,
|
||||
) -> Tuple[List[ChatSession], bool]:
|
||||
"""
|
||||
Search for chat sessions based on the provided query.
|
||||
If no query is provided, returns recent chat sessions.
|
||||
Fast full-text search on ChatSession + ChatMessage using tsvectors.
|
||||
|
||||
Returns a tuple of (chat_sessions, has_more)
|
||||
If no query is provided, returns the most recent chat sessions.
|
||||
Otherwise, searches both chat messages and session descriptions.
|
||||
|
||||
Returns a tuple of (sessions, has_more) where has_more indicates if
|
||||
there are additional results beyond the requested page.
|
||||
"""
|
||||
offset = (page - 1) * page_size
|
||||
offset_val = (page - 1) * page_size
|
||||
|
||||
# If no search query, we use standard SQLAlchemy pagination
|
||||
# If no query, just return the most recent sessions
|
||||
if not query or not query.strip():
|
||||
stmt = select(ChatSession)
|
||||
if user_id:
|
||||
stmt = (
|
||||
select(ChatSession)
|
||||
.order_by(desc(ChatSession.time_created))
|
||||
.offset(offset_val)
|
||||
.limit(page_size + 1)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(ChatSession.user_id == user_id)
|
||||
if not include_onyxbot_flows:
|
||||
stmt = stmt.where(ChatSession.onyxbot_flow.is_(False))
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(ChatSession.deleted.is_(False))
|
||||
|
||||
stmt = stmt.order_by(desc(ChatSession.time_created))
|
||||
|
||||
# Apply pagination
|
||||
stmt = stmt.offset(offset).limit(page_size + 1)
|
||||
result = db_session.execute(stmt.options(joinedload(ChatSession.persona)))
|
||||
chat_sessions = result.scalars().all()
|
||||
sessions = result.scalars().all()
|
||||
|
||||
has_more = len(chat_sessions) > page_size
|
||||
has_more = len(sessions) > page_size
|
||||
if has_more:
|
||||
chat_sessions = chat_sessions[:page_size]
|
||||
sessions = sessions[:page_size]
|
||||
|
||||
return list(chat_sessions), has_more
|
||||
return list(sessions), has_more
|
||||
|
||||
words = query.lower().strip().split()
|
||||
# Otherwise, proceed with full-text search
|
||||
query = query.strip()
|
||||
|
||||
# Message mach subquery
|
||||
message_matches = []
|
||||
for word in words:
|
||||
word_like = f"%{word}%"
|
||||
message_match: Select = (
|
||||
select(ChatMessage.chat_session_id, literal(1.0).label("search_rank"))
|
||||
.join(ChatSession, ChatSession.id == ChatMessage.chat_session_id)
|
||||
.where(func.lower(ChatMessage.message).like(word_like))
|
||||
)
|
||||
|
||||
if user_id:
|
||||
message_match = message_match.where(ChatSession.user_id == user_id)
|
||||
|
||||
message_matches.append(message_match)
|
||||
|
||||
if message_matches:
|
||||
message_matches_query = union_all(*message_matches).alias("message_matches")
|
||||
else:
|
||||
return [], False
|
||||
|
||||
# Description matches
|
||||
description_match: Select = select(
|
||||
ChatSession.id.label("chat_session_id"), literal(0.5).label("search_rank")
|
||||
).where(func.lower(ChatSession.description).like(f"%{query.lower()}%"))
|
||||
|
||||
if user_id:
|
||||
description_match = description_match.where(ChatSession.user_id == user_id)
|
||||
base_conditions = []
|
||||
if user_id is not None:
|
||||
base_conditions.append(ChatSession.user_id == user_id)
|
||||
if not include_onyxbot_flows:
|
||||
description_match = description_match.where(ChatSession.onyxbot_flow.is_(False))
|
||||
base_conditions.append(ChatSession.onyxbot_flow.is_(False))
|
||||
if not include_deleted:
|
||||
description_match = description_match.where(ChatSession.deleted.is_(False))
|
||||
base_conditions.append(ChatSession.deleted.is_(False))
|
||||
|
||||
# Combine all match sources
|
||||
combined_matches = union_all(
|
||||
message_matches_query.select(), description_match
|
||||
).alias("combined_matches")
|
||||
message_tsv: ColumnClause = column("message_tsv")
|
||||
description_tsv: ColumnClause = column("description_tsv")
|
||||
|
||||
# Use CTE to group and get max rank
|
||||
session_ranks = (
|
||||
select(
|
||||
combined_matches.c.chat_session_id,
|
||||
func.max(combined_matches.c.search_rank).label("rank"),
|
||||
)
|
||||
.group_by(combined_matches.c.chat_session_id)
|
||||
.alias("session_ranks")
|
||||
ts_query = func.plainto_tsquery("english", query)
|
||||
|
||||
description_session_ids = (
|
||||
select(ChatSession.id)
|
||||
.where(*base_conditions)
|
||||
.where(description_tsv.op("@@")(ts_query))
|
||||
)
|
||||
|
||||
# Get ranked sessions with pagination
|
||||
ranked_query = (
|
||||
db_session.query(session_ranks.c.chat_session_id, session_ranks.c.rank)
|
||||
.order_by(desc(session_ranks.c.rank), session_ranks.c.chat_session_id)
|
||||
.offset(offset)
|
||||
message_session_ids = (
|
||||
select(ChatMessage.chat_session_id)
|
||||
.join(ChatSession, ChatMessage.chat_session_id == ChatSession.id)
|
||||
.where(*base_conditions)
|
||||
.where(message_tsv.op("@@")(ts_query))
|
||||
)
|
||||
|
||||
combined_ids = description_session_ids.union(message_session_ids).alias(
|
||||
"combined_ids"
|
||||
)
|
||||
|
||||
final_stmt = (
|
||||
select(ChatSession)
|
||||
.join(combined_ids, ChatSession.id == combined_ids.c.id)
|
||||
.order_by(desc(ChatSession.time_created))
|
||||
.distinct()
|
||||
.offset(offset_val)
|
||||
.limit(page_size + 1)
|
||||
.options(joinedload(ChatSession.persona))
|
||||
)
|
||||
|
||||
result = ranked_query.all()
|
||||
session_objs = db_session.execute(final_stmt).scalars().all()
|
||||
|
||||
# Extract session IDs and ranks
|
||||
session_ids_with_ranks = {row.chat_session_id: row.rank for row in result}
|
||||
session_ids = list(session_ids_with_ranks.keys())
|
||||
|
||||
if not session_ids:
|
||||
return [], False
|
||||
|
||||
# Now, let's query the actual ChatSession objects using the IDs
|
||||
stmt = select(ChatSession).where(ChatSession.id.in_(session_ids))
|
||||
|
||||
if user_id:
|
||||
stmt = stmt.where(ChatSession.user_id == user_id)
|
||||
if not include_onyxbot_flows:
|
||||
stmt = stmt.where(ChatSession.onyxbot_flow.is_(False))
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(ChatSession.deleted.is_(False))
|
||||
|
||||
# Full objects with eager loading
|
||||
result = db_session.execute(stmt.options(joinedload(ChatSession.persona)))
|
||||
chat_sessions = result.scalars().all()
|
||||
|
||||
# Sort based on above ranking
|
||||
chat_sessions = sorted(
|
||||
chat_sessions,
|
||||
key=lambda session: (
|
||||
-session_ids_with_ranks.get(session.id, 0), # Rank (higher first)
|
||||
session.time_created.timestamp() * -1, # Then by time (newest first)
|
||||
),
|
||||
)
|
||||
|
||||
has_more = len(chat_sessions) > page_size
|
||||
has_more = len(session_objs) > page_size
|
||||
if has_more:
|
||||
chat_sessions = chat_sessions[:page_size]
|
||||
session_objs = session_objs[:page_size]
|
||||
|
||||
return chat_sessions, has_more
|
||||
return list(session_objs), has_more
|
||||
|
||||
@@ -104,7 +104,6 @@ def get_connector_credential_pairs_for_user(
|
||||
get_editable: bool = True,
|
||||
ids: list[int] | None = None,
|
||||
eager_load_connector: bool = False,
|
||||
include_user_files: bool = False,
|
||||
eager_load_credential: bool = False,
|
||||
eager_load_user: bool = False,
|
||||
) -> list[ConnectorCredentialPair]:
|
||||
@@ -127,9 +126,6 @@ def get_connector_credential_pairs_for_user(
|
||||
if ids:
|
||||
stmt = stmt.where(ConnectorCredentialPair.id.in_(ids))
|
||||
|
||||
if not include_user_files:
|
||||
stmt = stmt.where(ConnectorCredentialPair.is_user_file != True) # noqa: E712
|
||||
|
||||
return list(db_session.scalars(stmt).unique().all())
|
||||
|
||||
|
||||
@@ -157,16 +153,14 @@ def get_connector_credential_pairs_for_user_parallel(
|
||||
|
||||
|
||||
def get_connector_credential_pairs(
|
||||
db_session: Session, ids: list[int] | None = None, include_user_files: bool = False
|
||||
db_session: Session,
|
||||
ids: list[int] | None = None,
|
||||
) -> list[ConnectorCredentialPair]:
|
||||
stmt = select(ConnectorCredentialPair).distinct()
|
||||
|
||||
if ids:
|
||||
stmt = stmt.where(ConnectorCredentialPair.id.in_(ids))
|
||||
|
||||
if not include_user_files:
|
||||
stmt = stmt.where(ConnectorCredentialPair.is_user_file != True) # noqa: E712
|
||||
|
||||
return list(db_session.scalars(stmt).all())
|
||||
|
||||
|
||||
@@ -452,7 +446,6 @@ def add_credential_to_connector(
|
||||
initial_status: ConnectorCredentialPairStatus = ConnectorCredentialPairStatus.ACTIVE,
|
||||
last_successful_index_time: datetime | None = None,
|
||||
seeding_flow: bool = False,
|
||||
is_user_file: bool = False,
|
||||
) -> StatusResponse:
|
||||
connector = fetch_connector_by_id(connector_id, db_session)
|
||||
|
||||
@@ -518,7 +511,6 @@ def add_credential_to_connector(
|
||||
access_type=access_type,
|
||||
auto_sync_options=auto_sync_options,
|
||||
last_successful_index_time=last_successful_index_time,
|
||||
is_user_file=is_user_file,
|
||||
)
|
||||
db_session.add(association)
|
||||
db_session.flush() # make sure the association has an id
|
||||
|
||||
@@ -274,7 +274,7 @@ def get_document_counts_for_cc_pairs_parallel(
|
||||
def get_access_info_for_document(
|
||||
db_session: Session,
|
||||
document_id: str,
|
||||
) -> tuple[str, list[str | None], bool, list[int], list[int]] | None:
|
||||
) -> tuple[str, list[str | None], bool] | None:
|
||||
"""Gets access info for a single document by calling the get_access_info_for_documents function
|
||||
and passing a list with a single document ID.
|
||||
Args:
|
||||
@@ -294,7 +294,7 @@ def get_access_info_for_document(
|
||||
def get_access_info_for_documents(
|
||||
db_session: Session,
|
||||
document_ids: list[str],
|
||||
) -> Sequence[tuple[str, list[str | None], bool, list[int], list[int]]]:
|
||||
) -> Sequence[tuple[str, list[str | None], bool]]:
|
||||
"""Gets back all relevant access info for the given documents. This includes
|
||||
the user_ids for cc pairs that the document is associated with + whether any
|
||||
of the associated cc pairs are intending to make the document globally public.
|
||||
|
||||
@@ -605,6 +605,7 @@ def fetch_document_sets_for_document(
|
||||
result = fetch_document_sets_for_documents([document_id], db_session)
|
||||
if not result:
|
||||
return []
|
||||
|
||||
return result[0][1]
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import Index
|
||||
from sqlalchemy import Integer
|
||||
|
||||
from sqlalchemy import Sequence
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Text
|
||||
@@ -205,11 +206,6 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||
primaryjoin="User.id == foreign(ConnectorCredentialPair.creator_id)",
|
||||
)
|
||||
|
||||
folders: Mapped[list["UserFolder"]] = relationship(
|
||||
"UserFolder", back_populates="user"
|
||||
)
|
||||
files: Mapped[list["UserFile"]] = relationship("UserFile", back_populates="user")
|
||||
|
||||
@property
|
||||
def password_configured(self) -> bool:
|
||||
"""
|
||||
@@ -412,7 +408,6 @@ class ConnectorCredentialPair(Base):
|
||||
"""
|
||||
|
||||
__tablename__ = "connector_credential_pair"
|
||||
is_user_file: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# NOTE: this `id` column has to use `Sequence` instead of `autoincrement=True`
|
||||
# due to some SQLAlchemy quirks + this not being a primary key column
|
||||
id: Mapped[int] = mapped_column(
|
||||
@@ -499,10 +494,6 @@ class ConnectorCredentialPair(Base):
|
||||
primaryjoin="foreign(ConnectorCredentialPair.creator_id) == remote(User.id)",
|
||||
)
|
||||
|
||||
user_file: Mapped["UserFile"] = relationship(
|
||||
"UserFile", back_populates="cc_pair", uselist=False
|
||||
)
|
||||
|
||||
background_errors: Mapped[list["BackgroundError"]] = relationship(
|
||||
"BackgroundError", back_populates="cc_pair", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -1723,17 +1714,6 @@ class Persona(Base):
|
||||
secondary="persona__user_group",
|
||||
viewonly=True,
|
||||
)
|
||||
# Relationship to UserFile
|
||||
user_files: Mapped[list["UserFile"]] = relationship(
|
||||
"UserFile",
|
||||
secondary="persona__user_file",
|
||||
back_populates="assistants",
|
||||
)
|
||||
user_folders: Mapped[list["UserFolder"]] = relationship(
|
||||
"UserFolder",
|
||||
secondary="persona__user_folder",
|
||||
back_populates="assistants",
|
||||
)
|
||||
labels: Mapped[list["PersonaLabel"]] = relationship(
|
||||
"PersonaLabel",
|
||||
secondary=Persona__PersonaLabel.__table__,
|
||||
@@ -1750,24 +1730,6 @@ class Persona(Base):
|
||||
)
|
||||
|
||||
|
||||
class Persona__UserFolder(Base):
|
||||
__tablename__ = "persona__user_folder"
|
||||
|
||||
persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True)
|
||||
user_folder_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("user_folder.id"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class Persona__UserFile(Base):
|
||||
__tablename__ = "persona__user_file"
|
||||
|
||||
persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True)
|
||||
user_file_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("user_file.id"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class PersonaLabel(Base):
|
||||
__tablename__ = "persona_label"
|
||||
|
||||
@@ -2289,68 +2251,6 @@ class InputPrompt__User(Base):
|
||||
disabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class UserFolder(Base):
|
||||
__tablename__ = "user_folder"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
description: Mapped[str] = mapped_column(nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
# Mapped[datetime.datetime] = mapped_column(
|
||||
# DateTime(timezone=True), server_default=func.now()
|
||||
# )
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="folders")
|
||||
files: Mapped[list["UserFile"]] = relationship(back_populates="folder")
|
||||
assistants: Mapped[list["Persona"]] = relationship(
|
||||
"Persona",
|
||||
secondary=Persona__UserFolder.__table__,
|
||||
back_populates="user_folders",
|
||||
)
|
||||
|
||||
|
||||
class UserDocument(str, Enum):
|
||||
CHAT = "chat"
|
||||
RECENT = "recent"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
class UserFile(Base):
|
||||
__tablename__ = "user_file"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"), nullable=False)
|
||||
assistants: Mapped[list["Persona"]] = relationship(
|
||||
"Persona",
|
||||
secondary=Persona__UserFile.__table__,
|
||||
back_populates="user_files",
|
||||
)
|
||||
folder_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("user_folder.id"), nullable=True
|
||||
)
|
||||
|
||||
file_id: Mapped[str] = mapped_column(nullable=False)
|
||||
document_id: Mapped[str] = mapped_column(nullable=False)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
default=datetime.datetime.utcnow
|
||||
)
|
||||
user: Mapped["User"] = relationship(back_populates="files")
|
||||
folder: Mapped["UserFolder"] = relationship(back_populates="files")
|
||||
token_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
cc_pair_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("connector_credential_pair.id"), nullable=True, unique=True
|
||||
)
|
||||
cc_pair: Mapped["ConnectorCredentialPair"] = relationship(
|
||||
"ConnectorCredentialPair", back_populates="user_file"
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
Multi-tenancy related tables
|
||||
"""
|
||||
|
||||
@@ -33,8 +33,6 @@ from onyx.db.models import StarterMessage
|
||||
from onyx.db.models import Tool
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import User__UserGroup
|
||||
from onyx.db.models import UserFile
|
||||
from onyx.db.models import UserFolder
|
||||
from onyx.db.models import UserGroup
|
||||
from onyx.db.notification import create_notification
|
||||
from onyx.server.features.persona.models import PersonaSharedNotificationData
|
||||
@@ -102,9 +100,14 @@ def _add_user_filters(
|
||||
.correlate(Persona)
|
||||
)
|
||||
else:
|
||||
where_clause |= Persona.is_public == True # noqa: E712
|
||||
where_clause &= Persona.is_visible == True # noqa: E712
|
||||
# Group the public persona conditions
|
||||
public_condition = (Persona.is_public == True) & ( # noqa: E712
|
||||
Persona.is_visible == True # noqa: E712
|
||||
)
|
||||
|
||||
where_clause |= public_condition
|
||||
where_clause |= Persona__User.user_id == user.id
|
||||
|
||||
where_clause |= Persona.user_id == user.id
|
||||
|
||||
return stmt.where(where_clause)
|
||||
@@ -239,8 +242,6 @@ def create_update_persona(
|
||||
llm_relevance_filter=create_persona_request.llm_relevance_filter,
|
||||
llm_filter_extraction=create_persona_request.llm_filter_extraction,
|
||||
is_default_persona=create_persona_request.is_default_persona,
|
||||
user_file_ids=create_persona_request.user_file_ids,
|
||||
user_folder_ids=create_persona_request.user_folder_ids,
|
||||
)
|
||||
|
||||
versioned_make_persona_private = fetch_versioned_implementation(
|
||||
@@ -335,8 +336,6 @@ def get_personas_for_user(
|
||||
selectinload(Persona.groups),
|
||||
selectinload(Persona.users),
|
||||
selectinload(Persona.labels),
|
||||
selectinload(Persona.user_files),
|
||||
selectinload(Persona.user_folders),
|
||||
)
|
||||
|
||||
results = db_session.execute(stmt).scalars().all()
|
||||
@@ -431,8 +430,6 @@ def upsert_persona(
|
||||
builtin_persona: bool = False,
|
||||
is_default_persona: bool = False,
|
||||
label_ids: list[int] | None = None,
|
||||
user_file_ids: list[int] | None = None,
|
||||
user_folder_ids: list[int] | None = None,
|
||||
chunks_above: int = CONTEXT_CHUNKS_ABOVE,
|
||||
chunks_below: int = CONTEXT_CHUNKS_BELOW,
|
||||
) -> Persona:
|
||||
@@ -458,7 +455,6 @@ def upsert_persona(
|
||||
user=user,
|
||||
get_editable=True,
|
||||
)
|
||||
|
||||
# Fetch and attach tools by IDs
|
||||
tools = None
|
||||
if tool_ids is not None:
|
||||
@@ -477,26 +473,6 @@ def upsert_persona(
|
||||
if not document_sets and document_set_ids:
|
||||
raise ValueError("document_sets not found")
|
||||
|
||||
# Fetch and attach user_files by IDs
|
||||
user_files = None
|
||||
if user_file_ids is not None:
|
||||
user_files = (
|
||||
db_session.query(UserFile).filter(UserFile.id.in_(user_file_ids)).all()
|
||||
)
|
||||
if not user_files and user_file_ids:
|
||||
raise ValueError("user_files not found")
|
||||
|
||||
# Fetch and attach user_folders by IDs
|
||||
user_folders = None
|
||||
if user_folder_ids is not None:
|
||||
user_folders = (
|
||||
db_session.query(UserFolder)
|
||||
.filter(UserFolder.id.in_(user_folder_ids))
|
||||
.all()
|
||||
)
|
||||
if not user_folders and user_folder_ids:
|
||||
raise ValueError("user_folders not found")
|
||||
|
||||
# Fetch and attach prompts by IDs
|
||||
prompts = None
|
||||
if prompt_ids is not None:
|
||||
@@ -561,14 +537,6 @@ def upsert_persona(
|
||||
if tools is not None:
|
||||
existing_persona.tools = tools or []
|
||||
|
||||
if user_file_ids is not None:
|
||||
existing_persona.user_files.clear()
|
||||
existing_persona.user_files = user_files or []
|
||||
|
||||
if user_folder_ids is not None:
|
||||
existing_persona.user_folders.clear()
|
||||
existing_persona.user_folders = user_folders or []
|
||||
|
||||
# We should only update display priority if it is not already set
|
||||
if existing_persona.display_priority is None:
|
||||
existing_persona.display_priority = display_priority
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import datetime
|
||||
from typing import List
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.connectors.file.connector import _read_files_and_metadata
|
||||
from onyx.db.models import Persona
|
||||
from onyx.db.models import Persona__UserFile
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import UserFile
|
||||
from onyx.db.models import UserFolder
|
||||
from onyx.file_processing.extract_file_text import read_text_file
|
||||
from onyx.llm.factory import get_default_llms
|
||||
from onyx.natural_language_processing.utils import get_tokenizer
|
||||
from onyx.server.documents.connector import upload_files
|
||||
|
||||
USER_FILE_CONSTANT = "USER_FILE_CONNECTOR"
|
||||
|
||||
|
||||
def create_user_files(
|
||||
files: List[UploadFile],
|
||||
folder_id: int | None,
|
||||
user: User | None,
|
||||
db_session: Session,
|
||||
) -> list[UserFile]:
|
||||
upload_response = upload_files(files, db_session)
|
||||
user_files = []
|
||||
|
||||
context_files = _read_files_and_metadata(
|
||||
file_name=str(upload_response.file_paths[0]), db_session=db_session
|
||||
)
|
||||
|
||||
content, _ = read_text_file(next(context_files)[1])
|
||||
llm, _ = get_default_llms()
|
||||
|
||||
llm_tokenizer = get_tokenizer(
|
||||
model_name=llm.config.model_name,
|
||||
provider_type=llm.config.model_provider,
|
||||
)
|
||||
token_count = len(llm_tokenizer.encode(content))
|
||||
|
||||
for file_path, file in zip(upload_response.file_paths, files):
|
||||
new_file = UserFile(
|
||||
user_id=user.id if user else None,
|
||||
folder_id=folder_id,
|
||||
file_id=file_path,
|
||||
document_id="USER_FILE_CONNECTOR__" + file_path,
|
||||
name=file.filename,
|
||||
token_count=token_count,
|
||||
)
|
||||
db_session.add(new_file)
|
||||
user_files.append(new_file)
|
||||
db_session.commit()
|
||||
return user_files
|
||||
|
||||
|
||||
def get_user_files_from_folder(folder_id: int, db_session: Session) -> list[UserFile]:
|
||||
return db_session.query(UserFile).filter(UserFile.folder_id == folder_id).all()
|
||||
|
||||
|
||||
def share_file_with_assistant(
|
||||
file_id: int, assistant_id: int, db_session: Session
|
||||
) -> None:
|
||||
file = db_session.query(UserFile).filter(UserFile.id == file_id).first()
|
||||
assistant = db_session.query(Persona).filter(Persona.id == assistant_id).first()
|
||||
|
||||
if file and assistant:
|
||||
file.assistants.append(assistant)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def unshare_file_with_assistant(
|
||||
file_id: int, assistant_id: int, db_session: Session
|
||||
) -> None:
|
||||
db_session.query(Persona__UserFile).filter(
|
||||
and_(
|
||||
Persona__UserFile.user_file_id == file_id,
|
||||
Persona__UserFile.persona_id == assistant_id,
|
||||
)
|
||||
).delete()
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def share_folder_with_assistant(
|
||||
folder_id: int, assistant_id: int, db_session: Session
|
||||
) -> None:
|
||||
folder = db_session.query(UserFolder).filter(UserFolder.id == folder_id).first()
|
||||
assistant = db_session.query(Persona).filter(Persona.id == assistant_id).first()
|
||||
|
||||
if folder and assistant:
|
||||
for file in folder.files:
|
||||
share_file_with_assistant(file.id, assistant_id, db_session)
|
||||
|
||||
|
||||
def unshare_folder_with_assistant(
|
||||
folder_id: int, assistant_id: int, db_session: Session
|
||||
) -> None:
|
||||
folder = db_session.query(UserFolder).filter(UserFolder.id == folder_id).first()
|
||||
|
||||
if folder:
|
||||
for file in folder.files:
|
||||
unshare_file_with_assistant(file.id, assistant_id, db_session)
|
||||
|
||||
|
||||
def fetch_user_files_for_documents(
|
||||
document_ids: list[str],
|
||||
db_session: Session,
|
||||
) -> dict[str, None | int]:
|
||||
# Query UserFile objects for the given document_ids
|
||||
user_files = (
|
||||
db_session.query(UserFile).filter(UserFile.document_id.in_(document_ids)).all()
|
||||
)
|
||||
|
||||
# Create a dictionary mapping document_ids to UserFile objects
|
||||
result = {doc_id: None for doc_id in document_ids}
|
||||
for user_file in user_files:
|
||||
result[user_file.document_id] = user_file.id
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def upsert_user_folder(
|
||||
db_session: Session,
|
||||
id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
created_at: datetime.datetime | None = None,
|
||||
user: User | None = None,
|
||||
files: list[UserFile] | None = None,
|
||||
assistants: list[Persona] | None = None,
|
||||
) -> UserFolder:
|
||||
if id is not None:
|
||||
user_folder = db_session.query(UserFolder).filter_by(id=id).first()
|
||||
else:
|
||||
user_folder = (
|
||||
db_session.query(UserFolder).filter_by(name=name, user_id=user_id).first()
|
||||
)
|
||||
|
||||
if user_folder:
|
||||
if user_id is not None:
|
||||
user_folder.user_id = user_id
|
||||
if name is not None:
|
||||
user_folder.name = name
|
||||
if description is not None:
|
||||
user_folder.description = description
|
||||
if created_at is not None:
|
||||
user_folder.created_at = created_at
|
||||
if user is not None:
|
||||
user_folder.user = user
|
||||
if files is not None:
|
||||
user_folder.files = files
|
||||
if assistants is not None:
|
||||
user_folder.assistants = assistants
|
||||
else:
|
||||
user_folder = UserFolder(
|
||||
id=id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
created_at=created_at or datetime.datetime.utcnow(),
|
||||
user=user,
|
||||
files=files or [],
|
||||
assistants=assistants or [],
|
||||
)
|
||||
db_session.add(user_folder)
|
||||
|
||||
db_session.flush()
|
||||
return user_folder
|
||||
|
||||
|
||||
def get_user_folder_by_name(db_session: Session, name: str) -> UserFolder | None:
|
||||
return db_session.query(UserFolder).filter(UserFolder.name == name).first()
|
||||
@@ -112,16 +112,6 @@ schema DANSWER_CHUNK_NAME {
|
||||
rank: filter
|
||||
attribute: fast-search
|
||||
}
|
||||
field user_file type int {
|
||||
indexing: summary | attribute
|
||||
rank: filter
|
||||
attribute: fast-search
|
||||
}
|
||||
field user_folders type weightedset<int> {
|
||||
indexing: summary | attribute
|
||||
rank: filter
|
||||
attribute: fast-search
|
||||
}
|
||||
}
|
||||
|
||||
# If using different tokenization settings, the fieldset has to be removed, and the field must
|
||||
|
||||
@@ -645,8 +645,6 @@ class VespaIndex(DocumentIndex):
|
||||
tenant_id=tenant_id,
|
||||
large_chunks_enabled=large_chunks_enabled,
|
||||
)
|
||||
logger.error("CHECKing chunks")
|
||||
logger.error(doc_chunk_ids)
|
||||
|
||||
doc_chunk_count += len(doc_chunk_ids)
|
||||
|
||||
@@ -693,7 +691,6 @@ class VespaIndex(DocumentIndex):
|
||||
tenant_id=tenant_id,
|
||||
large_chunks_enabled=large_chunks_enabled,
|
||||
)
|
||||
|
||||
for doc_chunk_ids_batch in batch_generator(
|
||||
chunks_to_delete, BATCH_SIZE
|
||||
):
|
||||
|
||||
@@ -47,7 +47,6 @@ 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 TITLE
|
||||
from onyx.document_index.vespa_constants import TITLE_EMBEDDING
|
||||
from onyx.document_index.vespa_constants import USER_FILE
|
||||
from onyx.indexing.models import DocMetadataAwareIndexChunk
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
@@ -199,8 +198,6 @@ def _index_vespa_chunk(
|
||||
# which only calls VespaIndex.update
|
||||
ACCESS_CONTROL_LIST: {acl_entry: 1 for acl_entry in chunk.access.to_acl()},
|
||||
DOCUMENT_SETS: {document_set: 1 for document_set in chunk.document_sets},
|
||||
USER_FILE: chunk.user_file if chunk.user_file is not None else None,
|
||||
# USER_FOLDERS: {user_folder: 1 for user_folder in chunk.user_folders},
|
||||
BOOST: chunk.boost,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import timezone
|
||||
from onyx.configs.constants import INDEX_SEPARATOR
|
||||
from onyx.context.search.models import IndexFilters
|
||||
from onyx.document_index.interfaces import VespaChunkRequest
|
||||
from onyx.document_index.vespa_constants import ACCESS_CONTROL_LIST
|
||||
from onyx.document_index.vespa_constants import CHUNK_ID
|
||||
from onyx.document_index.vespa_constants import DOC_UPDATED_AT
|
||||
from onyx.document_index.vespa_constants import DOCUMENT_ID
|
||||
@@ -13,7 +14,6 @@ from onyx.document_index.vespa_constants import HIDDEN
|
||||
from onyx.document_index.vespa_constants import METADATA_LIST
|
||||
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_FILE
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
|
||||
@@ -27,26 +27,14 @@ def build_vespa_filters(
|
||||
remove_trailing_and: bool = False, # Set to True when using as a complete Vespa query
|
||||
) -> str:
|
||||
def _build_or_filters(key: str, vals: list[str] | None) -> str:
|
||||
"""For string-based 'contains' filters, e.g. WSET fields or array<string> fields."""
|
||||
if not key or not vals:
|
||||
return ""
|
||||
eq_elems = [f'{key} contains "{val}"' for val in vals if val]
|
||||
if not eq_elems:
|
||||
return ""
|
||||
or_clause = " or ".join(eq_elems)
|
||||
return f"({or_clause}) and "
|
||||
|
||||
def _build_int_or_filters(key: str, vals: list[int] | None) -> str:
|
||||
"""
|
||||
For an integer field filter.
|
||||
If vals is not None, we want *only* docs whose key matches one of vals.
|
||||
"""
|
||||
# If `vals` is None => skip the filter entirely
|
||||
if vals is None or not vals:
|
||||
if vals is None:
|
||||
return ""
|
||||
|
||||
# Otherwise build the OR filter
|
||||
eq_elems = [f"{key} = {val}" for val in vals]
|
||||
valid_vals = [val for val in vals if val]
|
||||
if not key or not valid_vals:
|
||||
return ""
|
||||
|
||||
eq_elems = [f'{key} contains "{elem}"' for elem in valid_vals]
|
||||
or_clause = " or ".join(eq_elems)
|
||||
result = f"({or_clause}) and "
|
||||
|
||||
@@ -54,55 +42,53 @@ def build_vespa_filters(
|
||||
|
||||
def _build_time_filter(
|
||||
cutoff: datetime | None,
|
||||
# Slightly over 3 Months, approximately 1 fiscal quarter
|
||||
untimed_doc_cutoff: timedelta = timedelta(days=92),
|
||||
) -> str:
|
||||
if not cutoff:
|
||||
return ""
|
||||
|
||||
# For Documents that don't have an updated at, filter them out for queries asking for
|
||||
# very recent documents (3 months) default. Documents that don't have an updated at
|
||||
# time are assigned 3 months for time decay value
|
||||
include_untimed = datetime.now(timezone.utc) - untimed_doc_cutoff > cutoff
|
||||
cutoff_secs = int(cutoff.timestamp())
|
||||
|
||||
if include_untimed:
|
||||
# Documents without updated_at are assigned -1 as their date
|
||||
return f"!({DOC_UPDATED_AT} < {cutoff_secs}) and "
|
||||
|
||||
return f"({DOC_UPDATED_AT} >= {cutoff_secs}) and "
|
||||
|
||||
# Start building the filter string
|
||||
filter_str = f"!({HIDDEN}=true) and " if not include_hidden else ""
|
||||
|
||||
# If running in multi-tenant mode
|
||||
# If running in multi-tenant mode, we may want to filter by tenant_id
|
||||
if filters.tenant_id and MULTI_TENANT:
|
||||
filter_str += f'({TENANT_ID} contains "{filters.tenant_id}") and '
|
||||
|
||||
# ACL filters
|
||||
# if filters.access_control_list is not None:
|
||||
# filter_str += _build_or_filters(ACCESS_CONTROL_LIST, filters.access_control_list)
|
||||
# CAREFUL touching this one, currently there is no second ACL double-check post retrieval
|
||||
if filters.access_control_list is not None:
|
||||
filter_str += _build_or_filters(
|
||||
ACCESS_CONTROL_LIST, filters.access_control_list
|
||||
)
|
||||
|
||||
# Source type filters
|
||||
source_strs = (
|
||||
[s.value for s in filters.source_type] if filters.source_type else None
|
||||
)
|
||||
filter_str += _build_or_filters(SOURCE_TYPE, source_strs)
|
||||
|
||||
# Tag filters
|
||||
tag_attributes = None
|
||||
if filters.tags:
|
||||
# build e.g. "tag_key|tag_value"
|
||||
tag_attributes = [
|
||||
f"{tag.tag_key}{INDEX_SEPARATOR}{tag.tag_value}" for tag in filters.tags
|
||||
]
|
||||
tags = filters.tags
|
||||
if tags:
|
||||
tag_attributes = [tag.tag_key + INDEX_SEPARATOR + tag.tag_value for tag in tags]
|
||||
filter_str += _build_or_filters(METADATA_LIST, tag_attributes)
|
||||
|
||||
# Document sets
|
||||
filter_str += _build_or_filters(DOCUMENT_SETS, filters.document_set)
|
||||
|
||||
# New: user_file_ids as integer filters
|
||||
filter_str += _build_int_or_filters(USER_FILE, filters.user_file_ids)
|
||||
|
||||
# Time filter
|
||||
filter_str += _build_time_filter(filters.time_cutoff)
|
||||
|
||||
# Trim trailing " and "
|
||||
if remove_trailing_and and filter_str.endswith(" and "):
|
||||
filter_str = filter_str[:-5]
|
||||
filter_str = filter_str[:-5] # We remove the trailing " and "
|
||||
|
||||
return filter_str
|
||||
|
||||
|
||||
@@ -66,8 +66,6 @@ EMBEDDINGS = "embeddings"
|
||||
TITLE_EMBEDDING = "title_embedding"
|
||||
ACCESS_CONTROL_LIST = "access_control_list"
|
||||
DOCUMENT_SETS = "document_sets"
|
||||
USER_FILE = "user_file"
|
||||
USER_FOLDERS = "user_folders"
|
||||
LARGE_CHUNK_REFERENCE_IDS = "large_chunk_reference_ids"
|
||||
METADATA = "metadata"
|
||||
METADATA_LIST = "metadata_list"
|
||||
|
||||
@@ -37,7 +37,6 @@ def delete_unstructured_api_key() -> None:
|
||||
def _sdk_partition_request(
|
||||
file: IO[Any], file_name: str, **kwargs: Any
|
||||
) -> operations.PartitionRequest:
|
||||
file.seek(0, 0)
|
||||
try:
|
||||
request = operations.PartitionRequest(
|
||||
partition_parameters=shared.PartitionParameters(
|
||||
|
||||
@@ -10,10 +10,7 @@ from sqlalchemy.orm import Session
|
||||
from onyx.configs.constants import FileOrigin
|
||||
from onyx.db.engine import get_session_with_current_tenant
|
||||
from onyx.db.models import ChatMessage
|
||||
from onyx.db.models import UserFile
|
||||
from onyx.db.models import UserFolder
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.file_store.models import ChatFileType
|
||||
from onyx.file_store.models import FileDescriptor
|
||||
from onyx.file_store.models import InMemoryChatFile
|
||||
from onyx.utils.b64 import get_image_type
|
||||
@@ -56,53 +53,6 @@ def load_all_chat_files(
|
||||
return files
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def load_user_folder(folder_id: int, db_session: Session) -> list[InMemoryChatFile]:
|
||||
user_files = (
|
||||
db_session.query(UserFile).filter(UserFile.folder_id == folder_id).all()
|
||||
)
|
||||
return [load_user_file(file.id, db_session) for file in user_files]
|
||||
|
||||
|
||||
def load_user_file(file_id: int, db_session: Session) -> InMemoryChatFile:
|
||||
user_file = db_session.query(UserFile).filter(UserFile.id == file_id).first()
|
||||
if not user_file:
|
||||
raise ValueError(f"User file with id {file_id} not found")
|
||||
|
||||
file_io = get_default_file_store(db_session).read_file(
|
||||
user_file.document_id, mode="b"
|
||||
)
|
||||
return InMemoryChatFile(
|
||||
file_id=str(user_file.id),
|
||||
content=file_io.read(),
|
||||
file_type=ChatFileType.PLAIN_TEXT,
|
||||
filename=user_file.name,
|
||||
)
|
||||
|
||||
|
||||
def load_all_user_files(
|
||||
user_file_ids: list[int],
|
||||
user_folder_ids: list[int],
|
||||
db_session: Session,
|
||||
) -> list[InMemoryChatFile]:
|
||||
return cast(
|
||||
list[InMemoryChatFile],
|
||||
run_functions_tuples_in_parallel(
|
||||
[(load_user_file, (file_id, db_session)) for file_id in user_file_ids]
|
||||
)
|
||||
+ [
|
||||
file
|
||||
for folder_id in user_folder_ids
|
||||
for file in load_user_folder(folder_id, db_session)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def save_file_from_url(url: str) -> str:
|
||||
"""NOTE: using multiple sessions here, since this is often called
|
||||
using multithreading. In practice, sharing a session has resulted in
|
||||
@@ -178,39 +128,3 @@ def save_files(urls: list[str], base64_files: list[str]) -> list[str]:
|
||||
]
|
||||
|
||||
return run_functions_tuples_in_parallel(funcs)
|
||||
|
||||
|
||||
def load_all_persona_files_for_chat(
|
||||
persona_id: int, db_session: Session
|
||||
) -> tuple[list[InMemoryChatFile], list[int]]:
|
||||
from onyx.db.models import Persona
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
persona = (
|
||||
db_session.query(Persona)
|
||||
.filter(Persona.id == persona_id)
|
||||
.options(
|
||||
joinedload(Persona.user_files),
|
||||
joinedload(Persona.user_folders).joinedload(UserFolder.files),
|
||||
)
|
||||
.one()
|
||||
)
|
||||
|
||||
persona_file_calls = [
|
||||
(load_user_file, (user_file.id, db_session)) for user_file in persona.user_files
|
||||
]
|
||||
persona_loaded_files = run_functions_tuples_in_parallel(persona_file_calls)
|
||||
|
||||
persona_folder_files = []
|
||||
persona_folder_file_ids = []
|
||||
for user_folder in persona.user_folders:
|
||||
folder_files = load_user_folder(user_folder.id, db_session)
|
||||
persona_folder_files.extend(folder_files)
|
||||
persona_folder_file_ids.extend([file.id for file in user_folder.files])
|
||||
|
||||
persona_files = list(persona_loaded_files) + persona_folder_files
|
||||
persona_file_ids = [
|
||||
file.id for file in persona.user_files
|
||||
] + persona_folder_file_ids
|
||||
|
||||
return persona_files, persona_file_ids
|
||||
|
||||
@@ -31,7 +31,6 @@ from onyx.db.models import Document as DBDocument
|
||||
from onyx.db.search_settings import get_current_search_settings
|
||||
from onyx.db.tag import create_or_add_document_tag
|
||||
from onyx.db.tag import create_or_add_document_tag_list
|
||||
from onyx.db.user_documents import fetch_user_files_for_documents
|
||||
from onyx.document_index.document_index_utils import (
|
||||
get_multipass_config,
|
||||
)
|
||||
@@ -403,10 +402,6 @@ def index_doc_batch(
|
||||
)
|
||||
}
|
||||
|
||||
doc_id_to_user_file_id: dict[str, int | None] = fetch_user_files_for_documents(
|
||||
document_ids=updatable_ids, db_session=db_session
|
||||
)
|
||||
|
||||
doc_id_to_previous_chunk_cnt: dict[str, int | None] = {
|
||||
document_id: chunk_count
|
||||
for document_id, chunk_count in fetch_chunk_counts_for_documents(
|
||||
@@ -438,7 +433,6 @@ def index_doc_batch(
|
||||
document_sets=set(
|
||||
doc_id_to_document_set.get(chunk.source_document.id, [])
|
||||
),
|
||||
user_file=doc_id_to_user_file_id.get(chunk.source_document.id, None),
|
||||
boost=(
|
||||
ctx.id_to_db_doc_map[chunk.source_document.id].boost
|
||||
if chunk.source_document.id in ctx.id_to_db_doc_map
|
||||
|
||||
@@ -87,8 +87,6 @@ class DocMetadataAwareIndexChunk(IndexChunk):
|
||||
tenant_id: str
|
||||
access: "DocumentAccess"
|
||||
document_sets: set[str]
|
||||
user_file: int | None
|
||||
# user_folders: list[int]
|
||||
boost: int
|
||||
|
||||
@classmethod
|
||||
@@ -97,8 +95,6 @@ class DocMetadataAwareIndexChunk(IndexChunk):
|
||||
index_chunk: IndexChunk,
|
||||
access: "DocumentAccess",
|
||||
document_sets: set[str],
|
||||
user_file: int | None,
|
||||
# user_folder: list[int],
|
||||
boost: int,
|
||||
tenant_id: str,
|
||||
) -> "DocMetadataAwareIndexChunk":
|
||||
@@ -107,8 +103,6 @@ class DocMetadataAwareIndexChunk(IndexChunk):
|
||||
**index_chunk_data,
|
||||
access=access,
|
||||
document_sets=document_sets,
|
||||
user_file=user_file,
|
||||
# user_folders=user_folders,
|
||||
boost=boost,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
@@ -97,7 +97,6 @@ from onyx.server.settings.api import basic_router as settings_router
|
||||
from onyx.server.token_rate_limits.api import (
|
||||
router as token_rate_limit_settings_router,
|
||||
)
|
||||
from onyx.server.user_documents.api import router as user_documents_router
|
||||
from onyx.server.utils import BasicAuthenticationError
|
||||
from onyx.setup import setup_multitenant_onyx
|
||||
from onyx.setup import setup_onyx
|
||||
@@ -296,7 +295,6 @@ def get_application() -> FastAPI:
|
||||
include_router_with_global_prefix_prepended(application, input_prompt_router)
|
||||
include_router_with_global_prefix_prepended(application, admin_input_prompt_router)
|
||||
include_router_with_global_prefix_prepended(application, cc_pair_router)
|
||||
include_router_with_global_prefix_prepended(application, user_documents_router)
|
||||
include_router_with_global_prefix_prepended(application, folder_router)
|
||||
include_router_with_global_prefix_prepended(application, document_set_router)
|
||||
include_router_with_global_prefix_prepended(application, search_settings_router)
|
||||
|
||||
@@ -23,7 +23,7 @@ from onyx.configs.constants import SearchFeedbackType
|
||||
from onyx.configs.onyxbot_configs import DANSWER_BOT_NUM_DOCS_TO_DISPLAY
|
||||
from onyx.context.search.models import SavedSearchDoc
|
||||
from onyx.db.chat import get_chat_session_by_message_id
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.engine import get_session_with_current_tenant
|
||||
from onyx.db.models import ChannelConfig
|
||||
from onyx.onyxbot.slack.constants import CONTINUE_IN_WEB_UI_ACTION_ID
|
||||
from onyx.onyxbot.slack.constants import DISLIKE_BLOCK_ACTION_ID
|
||||
@@ -410,12 +410,11 @@ def _build_qa_response_blocks(
|
||||
|
||||
|
||||
def _build_continue_in_web_ui_block(
|
||||
tenant_id: str,
|
||||
message_id: int | None,
|
||||
) -> Block:
|
||||
if message_id is None:
|
||||
raise ValueError("No message id provided to build continue in web ui block")
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
chat_session = get_chat_session_by_message_id(
|
||||
db_session=db_session,
|
||||
message_id=message_id,
|
||||
@@ -482,7 +481,6 @@ def build_follow_up_resolved_blocks(
|
||||
|
||||
def build_slack_response_blocks(
|
||||
answer: ChatOnyxBotResponse,
|
||||
tenant_id: str,
|
||||
message_info: SlackMessageInfo,
|
||||
channel_conf: ChannelConfig | None,
|
||||
use_citations: bool,
|
||||
@@ -517,7 +515,6 @@ def build_slack_response_blocks(
|
||||
if channel_conf and channel_conf.get("show_continue_in_web_ui"):
|
||||
web_follow_up_block.append(
|
||||
_build_continue_in_web_ui_block(
|
||||
tenant_id=tenant_id,
|
||||
message_id=answer.chat_message_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from onyx.configs.constants import SearchFeedbackType
|
||||
from onyx.configs.onyxbot_configs import DANSWER_FOLLOWUP_EMOJI
|
||||
from onyx.connectors.slack.utils import expert_info_from_slack_id
|
||||
from onyx.connectors.slack.utils import make_slack_api_rate_limited
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.engine import get_session_with_current_tenant
|
||||
from onyx.db.feedback import create_chat_message_feedback
|
||||
from onyx.db.feedback import create_doc_retrieval_feedback
|
||||
from onyx.onyxbot.slack.blocks import build_follow_up_resolved_blocks
|
||||
@@ -114,7 +114,7 @@ def handle_generate_answer_button(
|
||||
thread_ts=thread_ts,
|
||||
)
|
||||
|
||||
with get_session_with_tenant(tenant_id=client.tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
slack_channel_config = get_slack_channel_config_for_bot_and_channel(
|
||||
db_session=db_session,
|
||||
slack_bot_id=client.slack_bot_id,
|
||||
@@ -136,7 +136,6 @@ def handle_generate_answer_button(
|
||||
slack_channel_config=slack_channel_config,
|
||||
receiver_ids=None,
|
||||
client=client.web_client,
|
||||
tenant_id=client.tenant_id,
|
||||
channel=channel_id,
|
||||
logger=logger,
|
||||
feedback_reminder_id=None,
|
||||
@@ -151,11 +150,10 @@ def handle_slack_feedback(
|
||||
user_id_to_post_confirmation: str,
|
||||
channel_id_to_post_confirmation: str,
|
||||
thread_ts_to_post_confirmation: str,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
message_id, doc_id, doc_rank = decompose_action_id(feedback_id)
|
||||
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
if feedback_type in [LIKE_BLOCK_ACTION_ID, DISLIKE_BLOCK_ACTION_ID]:
|
||||
create_chat_message_feedback(
|
||||
is_positive=feedback_type == LIKE_BLOCK_ACTION_ID,
|
||||
@@ -246,7 +244,7 @@ def handle_followup_button(
|
||||
|
||||
tag_ids: list[str] = []
|
||||
group_ids: list[str] = []
|
||||
with get_session_with_tenant(tenant_id=client.tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
channel_name, is_dm = get_channel_name_from_id(
|
||||
client=client.web_client, channel_id=channel_id
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from slack_sdk.errors import SlackApiError
|
||||
|
||||
from onyx.configs.onyxbot_configs import DANSWER_BOT_FEEDBACK_REMINDER
|
||||
from onyx.configs.onyxbot_configs import DANSWER_REACT_EMOJI
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.engine import get_session_with_current_tenant
|
||||
from onyx.db.models import SlackChannelConfig
|
||||
from onyx.db.users import add_slack_user_if_not_exists
|
||||
from onyx.onyxbot.slack.blocks import get_feedback_reminder_blocks
|
||||
@@ -109,7 +109,6 @@ def handle_message(
|
||||
slack_channel_config: SlackChannelConfig,
|
||||
client: WebClient,
|
||||
feedback_reminder_id: str | None,
|
||||
tenant_id: str,
|
||||
) -> bool:
|
||||
"""Potentially respond to the user message depending on filters and if an answer was generated
|
||||
|
||||
@@ -135,9 +134,7 @@ def handle_message(
|
||||
action = "slack_tag_message"
|
||||
elif is_bot_dm:
|
||||
action = "slack_dm_message"
|
||||
slack_usage_report(
|
||||
action=action, sender_id=sender_id, client=client, tenant_id=tenant_id
|
||||
)
|
||||
slack_usage_report(action=action, sender_id=sender_id, client=client)
|
||||
|
||||
document_set_names: list[str] | None = None
|
||||
persona = slack_channel_config.persona if slack_channel_config else None
|
||||
@@ -218,7 +215,7 @@ def handle_message(
|
||||
except SlackApiError as e:
|
||||
logger.error(f"Was not able to react to user message due to: {e}")
|
||||
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
if message_info.email:
|
||||
add_slack_user_if_not_exists(db_session, message_info.email)
|
||||
|
||||
@@ -244,6 +241,5 @@ def handle_message(
|
||||
channel=channel,
|
||||
logger=logger,
|
||||
feedback_reminder_id=feedback_reminder_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
return issue_with_regular_answer
|
||||
|
||||
@@ -24,7 +24,6 @@ from onyx.context.search.enums import OptionalSearchSetting
|
||||
from onyx.context.search.models import BaseFilters
|
||||
from onyx.context.search.models import RetrievalDetails
|
||||
from onyx.db.engine import get_session_with_current_tenant
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.models import SlackChannelConfig
|
||||
from onyx.db.models import User
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
@@ -72,7 +71,6 @@ def handle_regular_answer(
|
||||
channel: str,
|
||||
logger: OnyxLoggingAdapter,
|
||||
feedback_reminder_id: str | None,
|
||||
tenant_id: str,
|
||||
num_retries: int = DANSWER_BOT_NUM_RETRIES,
|
||||
thread_context_percent: float = MAX_THREAD_CONTEXT_PERCENTAGE,
|
||||
should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS,
|
||||
@@ -87,7 +85,7 @@ def handle_regular_answer(
|
||||
user = None
|
||||
if message_info.is_bot_dm:
|
||||
if message_info.email:
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
user = get_user_by_email(message_info.email, db_session)
|
||||
|
||||
document_set_names: list[str] | None = None
|
||||
@@ -96,7 +94,7 @@ def handle_regular_answer(
|
||||
# This way slack flow always has a persona
|
||||
persona = slack_channel_config.persona
|
||||
if not persona:
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
persona = get_persona_by_id(DEFAULT_PERSONA_ID, user, db_session)
|
||||
document_set_names = [
|
||||
document_set.name for document_set in persona.document_sets
|
||||
@@ -157,7 +155,7 @@ def handle_regular_answer(
|
||||
def _get_slack_answer(
|
||||
new_message_request: CreateChatMessageRequest, onyx_user: User | None
|
||||
) -> ChatOnyxBotResponse:
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
packets = stream_chat_message_objects(
|
||||
new_msg_req=new_message_request,
|
||||
user=onyx_user,
|
||||
@@ -197,7 +195,7 @@ def handle_regular_answer(
|
||||
enable_auto_detect_filters=auto_detect_filters,
|
||||
)
|
||||
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
answer_request = prepare_chat_message_request(
|
||||
message_text=user_message.message,
|
||||
user=user,
|
||||
@@ -361,7 +359,6 @@ def handle_regular_answer(
|
||||
return True
|
||||
|
||||
all_blocks = build_slack_response_blocks(
|
||||
tenant_id=tenant_id,
|
||||
message_info=message_info,
|
||||
answer=answer,
|
||||
channel_conf=channel_conf,
|
||||
|
||||
@@ -37,6 +37,7 @@ from onyx.context.search.retrieval.search_runner import (
|
||||
download_nltk_data,
|
||||
)
|
||||
from onyx.db.engine import get_all_tenant_ids
|
||||
from onyx.db.engine import get_session_with_current_tenant
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.models import SlackBot
|
||||
from onyx.db.search_settings import get_current_search_settings
|
||||
@@ -92,6 +93,7 @@ from shared_configs.configs import MODEL_SERVER_PORT
|
||||
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
|
||||
from shared_configs.configs import SLACK_CHANNEL_ID
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -347,7 +349,7 @@ class SlackbotHandler:
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
|
||||
try:
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
# Attempt to fetch Slack bots
|
||||
try:
|
||||
bots = list(fetch_slack_bots(db_session=db_session))
|
||||
@@ -586,7 +588,7 @@ def prefilter_requests(req: SocketModeRequest, client: TenantSocketModeClient) -
|
||||
channel_name, _ = get_channel_name_from_id(
|
||||
client=client.web_client, channel_id=channel
|
||||
)
|
||||
with get_session_with_tenant(tenant_id=client.tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
slack_channel_config = get_slack_channel_config_for_bot_and_channel(
|
||||
db_session=db_session,
|
||||
slack_bot_id=client.slack_bot_id,
|
||||
@@ -680,7 +682,6 @@ def process_feedback(req: SocketModeRequest, client: TenantSocketModeClient) ->
|
||||
user_id_to_post_confirmation=user_id,
|
||||
channel_id_to_post_confirmation=channel_id,
|
||||
thread_ts_to_post_confirmation=thread_ts,
|
||||
tenant_id=client.tenant_id,
|
||||
)
|
||||
|
||||
query_event_id, _, _ = decompose_action_id(feedback_id)
|
||||
@@ -796,8 +797,9 @@ def process_message(
|
||||
respond_every_channel: bool = DANSWER_BOT_RESPOND_EVERY_CHANNEL,
|
||||
notify_no_answer: bool = NOTIFY_SLACKBOT_NO_ANSWER,
|
||||
) -> None:
|
||||
tenant_id = get_current_tenant_id()
|
||||
logger.debug(
|
||||
f"Received Slack request of type: '{req.type}' for tenant, {client.tenant_id}"
|
||||
f"Received Slack request of type: '{req.type}' for tenant, {tenant_id}"
|
||||
)
|
||||
|
||||
# Throw out requests that can't or shouldn't be handled
|
||||
@@ -810,50 +812,39 @@ def process_message(
|
||||
client=client.web_client, channel_id=channel
|
||||
)
|
||||
|
||||
token: Token[str | None] | None = None
|
||||
# Set the current tenant ID at the beginning for all DB calls within this thread
|
||||
if client.tenant_id:
|
||||
logger.info(f"Setting tenant ID to {client.tenant_id}")
|
||||
token = CURRENT_TENANT_ID_CONTEXTVAR.set(client.tenant_id)
|
||||
try:
|
||||
with get_session_with_tenant(tenant_id=client.tenant_id) as db_session:
|
||||
slack_channel_config = get_slack_channel_config_for_bot_and_channel(
|
||||
db_session=db_session,
|
||||
slack_bot_id=client.slack_bot_id,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
slack_channel_config = get_slack_channel_config_for_bot_and_channel(
|
||||
db_session=db_session,
|
||||
slack_bot_id=client.slack_bot_id,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
|
||||
follow_up = bool(
|
||||
slack_channel_config.channel_config
|
||||
and slack_channel_config.channel_config.get("follow_up_tags")
|
||||
is not None
|
||||
)
|
||||
follow_up = bool(
|
||||
slack_channel_config.channel_config
|
||||
and slack_channel_config.channel_config.get("follow_up_tags") is not None
|
||||
)
|
||||
|
||||
feedback_reminder_id = schedule_feedback_reminder(
|
||||
details=details, client=client.web_client, include_followup=follow_up
|
||||
)
|
||||
feedback_reminder_id = schedule_feedback_reminder(
|
||||
details=details, client=client.web_client, include_followup=follow_up
|
||||
)
|
||||
|
||||
failed = handle_message(
|
||||
message_info=details,
|
||||
slack_channel_config=slack_channel_config,
|
||||
client=client.web_client,
|
||||
feedback_reminder_id=feedback_reminder_id,
|
||||
tenant_id=client.tenant_id,
|
||||
)
|
||||
failed = handle_message(
|
||||
message_info=details,
|
||||
slack_channel_config=slack_channel_config,
|
||||
client=client.web_client,
|
||||
feedback_reminder_id=feedback_reminder_id,
|
||||
)
|
||||
|
||||
if failed:
|
||||
if feedback_reminder_id:
|
||||
remove_scheduled_feedback_reminder(
|
||||
client=client.web_client,
|
||||
channel=details.sender_id,
|
||||
msg_id=feedback_reminder_id,
|
||||
)
|
||||
# Skipping answering due to pre-filtering is not considered a failure
|
||||
if notify_no_answer:
|
||||
apologize_for_fail(details, client)
|
||||
finally:
|
||||
if token:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
|
||||
if failed:
|
||||
if feedback_reminder_id:
|
||||
remove_scheduled_feedback_reminder(
|
||||
client=client.web_client,
|
||||
channel=details.sender_id,
|
||||
msg_id=feedback_reminder_id,
|
||||
)
|
||||
# Skipping answering due to pre-filtering is not considered a failure
|
||||
if notify_no_answer:
|
||||
apologize_for_fail(details, client)
|
||||
|
||||
|
||||
def acknowledge_message(req: SocketModeRequest, client: TenantSocketModeClient) -> None:
|
||||
|
||||
@@ -4,6 +4,8 @@ import re
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
|
||||
@@ -30,7 +32,7 @@ from onyx.configs.onyxbot_configs import (
|
||||
)
|
||||
from onyx.connectors.slack.utils import make_slack_api_rate_limited
|
||||
from onyx.connectors.slack.utils import SlackTextCleaner
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.engine import get_session_with_current_tenant
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.llm.exceptions import GenAIDisabledException
|
||||
from onyx.llm.factory import get_default_llms
|
||||
@@ -43,6 +45,7 @@ from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.telemetry import optional_telemetry
|
||||
from onyx.utils.telemetry import RecordType
|
||||
from onyx.utils.text_processing import replace_whitespaces_w_space
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -569,9 +572,7 @@ def read_slack_thread(
|
||||
return thread_messages
|
||||
|
||||
|
||||
def slack_usage_report(
|
||||
action: str, sender_id: str | None, client: WebClient, tenant_id: str
|
||||
) -> None:
|
||||
def slack_usage_report(action: str, sender_id: str | None, client: WebClient) -> None:
|
||||
if DISABLE_TELEMETRY:
|
||||
return
|
||||
|
||||
@@ -583,14 +584,13 @@ def slack_usage_report(
|
||||
logger.warning("Unable to find sender email")
|
||||
|
||||
if sender_email is not None:
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
onyx_user = get_user_by_email(email=sender_email, db_session=db_session)
|
||||
|
||||
optional_telemetry(
|
||||
record_type=RecordType.USAGE,
|
||||
data={"action": action},
|
||||
user_id=str(onyx_user.id) if onyx_user else "Non-Onyx-Or-No-Auth-User",
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -665,5 +665,28 @@ def get_feedback_visibility() -> FeedbackVisibility:
|
||||
class TenantSocketModeClient(SocketModeClient):
|
||||
def __init__(self, tenant_id: str, slack_bot_id: int, *args: Any, **kwargs: Any):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.tenant_id = tenant_id
|
||||
self._tenant_id = tenant_id
|
||||
self.slack_bot_id = slack_bot_id
|
||||
|
||||
@contextmanager
|
||||
def _set_tenant_context(self) -> Generator[None, None, None]:
|
||||
token = None
|
||||
try:
|
||||
if self._tenant_id:
|
||||
token = CURRENT_TENANT_ID_CONTEXTVAR.set(self._tenant_id)
|
||||
yield
|
||||
finally:
|
||||
if token:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
|
||||
|
||||
def enqueue_message(self, message: str) -> None:
|
||||
with self._set_tenant_context():
|
||||
super().enqueue_message(message)
|
||||
|
||||
def process_message(self) -> None:
|
||||
with self._set_tenant_context():
|
||||
super().process_message()
|
||||
|
||||
def run_message_listeners(self, message: dict, raw_message: str) -> None:
|
||||
with self._set_tenant_context():
|
||||
super().run_message_listeners(message, raw_message)
|
||||
|
||||
@@ -91,7 +91,6 @@ def _create_indexable_chunks(
|
||||
tenant_id=tenant_id if MULTI_TENANT else POSTGRES_DEFAULT_SCHEMA,
|
||||
access=default_public_access,
|
||||
document_sets=set(),
|
||||
user_file=None,
|
||||
boost=DEFAULT_BOOST,
|
||||
large_chunk_id=None,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ from onyx.configs.chat_configs import INPUT_PROMPT_YAML
|
||||
from onyx.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT
|
||||
from onyx.configs.chat_configs import PERSONAS_YAML
|
||||
from onyx.configs.chat_configs import PROMPTS_YAML
|
||||
from onyx.configs.chat_configs import USER_FOLDERS_YAML
|
||||
from onyx.context.search.enums import RecencyBiasSetting
|
||||
from onyx.db.document_set import get_or_create_document_set_by_name
|
||||
from onyx.db.input_prompt import insert_input_prompt_if_not_exists
|
||||
@@ -16,30 +15,6 @@ from onyx.db.models import Tool as ToolDBModel
|
||||
from onyx.db.persona import upsert_persona
|
||||
from onyx.db.prompts import get_prompt_by_name
|
||||
from onyx.db.prompts import upsert_prompt
|
||||
from onyx.db.user_documents import upsert_user_folder
|
||||
|
||||
|
||||
def load_user_folders_from_yaml(
|
||||
db_session: Session,
|
||||
user_folders_yaml: str = USER_FOLDERS_YAML,
|
||||
) -> None:
|
||||
with open(user_folders_yaml, "r") as file:
|
||||
data = yaml.safe_load(file)
|
||||
|
||||
all_user_folders = data.get("user_folders", [])
|
||||
for user_folder in all_user_folders:
|
||||
upsert_user_folder(
|
||||
db_session=db_session,
|
||||
id=user_folder.get("id"),
|
||||
user_id=user_folder.get("user_id"),
|
||||
name=user_folder.get("name"),
|
||||
description=user_folder.get("description"),
|
||||
created_at=user_folder.get("created_at"),
|
||||
user=user_folder.get("user"),
|
||||
files=user_folder.get("files"),
|
||||
assistants=user_folder.get("assistants"),
|
||||
)
|
||||
db_session.flush()
|
||||
|
||||
|
||||
def load_prompts_from_yaml(
|
||||
@@ -204,4 +179,3 @@ def load_chat_yamls(
|
||||
load_prompts_from_yaml(db_session, prompt_yaml)
|
||||
load_personas_from_yaml(db_session, personas_yaml)
|
||||
load_input_prompts_from_yaml(db_session, input_prompts_yaml)
|
||||
load_user_folders_from_yaml(db_session)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
user_folders:
|
||||
- id: -1
|
||||
name: "Recent Documents"
|
||||
description: "Documents uploaded by the user"
|
||||
files: []
|
||||
assistants: []
|
||||
@@ -646,7 +646,6 @@ def associate_credential_to_connector(
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except ValidationError as e:
|
||||
# If validation fails, delete the connector and commit the changes
|
||||
# Ensures we don't leave invalid connectors in the database
|
||||
@@ -660,10 +659,14 @@ def associate_credential_to_connector(
|
||||
)
|
||||
except IntegrityError as e:
|
||||
logger.error(f"IntegrityError: {e}")
|
||||
delete_connector(db_session, connector_id)
|
||||
db_session.commit()
|
||||
|
||||
raise HTTPException(status_code=400, detail="Name must be unique")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error: {e}")
|
||||
|
||||
raise HTTPException(status_code=500, detail="Unexpected error")
|
||||
|
||||
|
||||
|
||||
@@ -389,7 +389,12 @@ def check_drive_tokens(
|
||||
return AuthStatus(authenticated=True)
|
||||
|
||||
|
||||
def upload_files(files: list[UploadFile], db_session: Session) -> FileUploadResponse:
|
||||
@router.post("/admin/connector/file/upload")
|
||||
def upload_files(
|
||||
files: list[UploadFile],
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> FileUploadResponse:
|
||||
for file in files:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="File name cannot be empty")
|
||||
@@ -450,15 +455,6 @@ def upload_files(files: list[UploadFile], db_session: Session) -> FileUploadResp
|
||||
return FileUploadResponse(file_paths=deduped_file_paths)
|
||||
|
||||
|
||||
@router.post("/admin/connector/file/upload")
|
||||
def upload_files_api(
|
||||
files: list[UploadFile],
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> FileUploadResponse:
|
||||
return upload_files(files, db_session)
|
||||
|
||||
|
||||
@router.get("/admin/connector")
|
||||
def get_connectors_by_credential(
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
@@ -1046,16 +1042,55 @@ def connector_run_once(
|
||||
status_code=400,
|
||||
detail="Connector has no valid credentials, cannot create index attempts.",
|
||||
)
|
||||
try:
|
||||
num_triggers = trigger_indexing_for_cc_pair(
|
||||
credential_ids,
|
||||
connector_id,
|
||||
run_info.from_beginning,
|
||||
tenant_id,
|
||||
db_session,
|
||||
|
||||
# Prevents index attempts for cc pairs that already have an index attempt currently running
|
||||
skipped_credentials = [
|
||||
credential_id
|
||||
for credential_id in credential_ids
|
||||
if get_index_attempts_for_cc_pair(
|
||||
cc_pair_identifier=ConnectorCredentialPairIdentifier(
|
||||
connector_id=run_info.connector_id,
|
||||
credential_id=credential_id,
|
||||
),
|
||||
only_current=True,
|
||||
db_session=db_session,
|
||||
disinclude_finished=True,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
]
|
||||
|
||||
connector_credential_pairs = [
|
||||
get_connector_credential_pair(
|
||||
db_session=db_session,
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
for credential_id in credential_ids
|
||||
if credential_id not in skipped_credentials
|
||||
]
|
||||
|
||||
num_triggers = 0
|
||||
for cc_pair in connector_credential_pairs:
|
||||
if cc_pair is not None:
|
||||
indexing_mode = IndexingMode.UPDATE
|
||||
if run_info.from_beginning:
|
||||
indexing_mode = IndexingMode.REINDEX
|
||||
|
||||
mark_ccpair_with_indexing_trigger(cc_pair.id, indexing_mode, db_session)
|
||||
num_triggers += 1
|
||||
|
||||
logger.info(
|
||||
f"connector_run_once - marking cc_pair with indexing trigger: "
|
||||
f"connector={run_info.connector_id} "
|
||||
f"cc_pair={cc_pair.id} "
|
||||
f"indexing_trigger={indexing_mode}"
|
||||
)
|
||||
|
||||
# run the beat task to pick up the triggers immediately
|
||||
primary_app.send_task(
|
||||
OnyxCeleryTask.CHECK_FOR_INDEXING,
|
||||
priority=OnyxCeleryPriority.HIGH,
|
||||
kwargs={"tenant_id": tenant_id},
|
||||
)
|
||||
|
||||
logger.info("connector_run_once - running check_for_indexing")
|
||||
|
||||
@@ -1229,82 +1264,3 @@ def get_basic_connector_indexing_status(
|
||||
for cc_pair in cc_pairs
|
||||
if cc_pair.connector.source != DocumentSource.INGESTION_API
|
||||
]
|
||||
|
||||
|
||||
def trigger_indexing_for_cc_pair(
|
||||
specified_credential_ids: list[int],
|
||||
connector_id: int,
|
||||
from_beginning: bool,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
) -> int:
|
||||
try:
|
||||
possible_credential_ids = get_connector_credential_ids(connector_id, db_session)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Connector by id {connector_id} does not exist: {str(e)}")
|
||||
|
||||
if not specified_credential_ids:
|
||||
credential_ids = possible_credential_ids
|
||||
else:
|
||||
if set(specified_credential_ids).issubset(set(possible_credential_ids)):
|
||||
credential_ids = specified_credential_ids
|
||||
else:
|
||||
raise ValueError(
|
||||
"Not all specified credentials are associated with connector"
|
||||
)
|
||||
|
||||
if not credential_ids:
|
||||
raise ValueError(
|
||||
"Connector has no valid credentials, cannot create index attempts."
|
||||
)
|
||||
|
||||
# Prevents index attempts for cc pairs that already have an index attempt currently running
|
||||
skipped_credentials = [
|
||||
credential_id
|
||||
for credential_id in credential_ids
|
||||
if get_index_attempts_for_cc_pair(
|
||||
cc_pair_identifier=ConnectorCredentialPairIdentifier(
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
),
|
||||
only_current=True,
|
||||
db_session=db_session,
|
||||
disinclude_finished=True,
|
||||
)
|
||||
]
|
||||
|
||||
connector_credential_pairs = [
|
||||
get_connector_credential_pair(
|
||||
db_session=db_session,
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
for credential_id in credential_ids
|
||||
if credential_id not in skipped_credentials
|
||||
]
|
||||
|
||||
num_triggers = 0
|
||||
for cc_pair in connector_credential_pairs:
|
||||
if cc_pair is not None:
|
||||
indexing_mode = IndexingMode.UPDATE
|
||||
if from_beginning:
|
||||
indexing_mode = IndexingMode.REINDEX
|
||||
|
||||
mark_ccpair_with_indexing_trigger(cc_pair.id, indexing_mode, db_session)
|
||||
num_triggers += 1
|
||||
|
||||
logger.info(
|
||||
f"connector_run_once - marking cc_pair with indexing trigger: "
|
||||
f"connector={connector_id} "
|
||||
f"cc_pair={cc_pair.id} "
|
||||
f"indexing_trigger={indexing_mode}"
|
||||
)
|
||||
|
||||
# run the beat task to pick up the triggers immediately
|
||||
primary_app.send_task(
|
||||
OnyxCeleryTask.CHECK_FOR_INDEXING,
|
||||
priority=OnyxCeleryPriority.HIGH,
|
||||
kwargs={"tenant_id": tenant_id},
|
||||
)
|
||||
|
||||
return num_triggers
|
||||
|
||||
@@ -122,7 +122,6 @@ class CredentialBase(BaseModel):
|
||||
name: str | None = None
|
||||
curator_public: bool = False
|
||||
groups: list[int] = Field(default_factory=list)
|
||||
is_user_file: bool = False
|
||||
|
||||
|
||||
class CredentialSnapshot(CredentialBase):
|
||||
@@ -393,7 +392,7 @@ class FileUploadResponse(BaseModel):
|
||||
|
||||
|
||||
class ObjectCreationIdResponse(BaseModel):
|
||||
id: int
|
||||
id: int | str
|
||||
credential: CredentialSnapshot | None = None
|
||||
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ from onyx.db.models import User
|
||||
from onyx.server.features.folder.models import DeleteFolderOptions
|
||||
from onyx.server.features.folder.models import FolderChatSessionRequest
|
||||
from onyx.server.features.folder.models import FolderCreationRequest
|
||||
from onyx.server.features.folder.models import FolderResponse
|
||||
from onyx.server.features.folder.models import FolderUpdateRequest
|
||||
from onyx.server.features.folder.models import GetUserFoldersResponse
|
||||
from onyx.server.features.folder.models import UserFolderSnapshot
|
||||
from onyx.server.models import DisplayPriorityRequest
|
||||
from onyx.server.query_and_chat.models import ChatSessionDetails
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_folders(
|
||||
folders.sort()
|
||||
return GetUserFoldersResponse(
|
||||
folders=[
|
||||
UserFolderSnapshot(
|
||||
FolderResponse(
|
||||
folder_id=folder.id,
|
||||
folder_name=folder.name,
|
||||
display_priority=folder.display_priority,
|
||||
@@ -49,6 +49,7 @@ def get_folders(
|
||||
name=chat_session.description,
|
||||
persona_id=chat_session.persona_id,
|
||||
time_created=chat_session.time_created.isoformat(),
|
||||
time_updated=chat_session.time_updated.isoformat(),
|
||||
shared_status=chat_session.shared_status,
|
||||
folder_id=folder.id,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
||||
from onyx.server.query_and_chat.models import ChatSessionDetails
|
||||
|
||||
|
||||
class UserFolderSnapshot(BaseModel):
|
||||
class FolderResponse(BaseModel):
|
||||
folder_id: int
|
||||
folder_name: str | None
|
||||
display_priority: int
|
||||
@@ -13,7 +13,7 @@ class UserFolderSnapshot(BaseModel):
|
||||
|
||||
|
||||
class GetUserFoldersResponse(BaseModel):
|
||||
folders: list[UserFolderSnapshot]
|
||||
folders: list[FolderResponse]
|
||||
|
||||
|
||||
class FolderCreationRequest(BaseModel):
|
||||
|
||||
@@ -26,7 +26,6 @@ from onyx.db.persona import create_assistant_label
|
||||
from onyx.db.persona import create_update_persona
|
||||
from onyx.db.persona import delete_persona_label
|
||||
from onyx.db.persona import get_assistant_labels
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
from onyx.db.persona import get_personas_for_user
|
||||
from onyx.db.persona import mark_persona_as_deleted
|
||||
@@ -56,9 +55,11 @@ from onyx.server.models import DisplayPriorityRequest
|
||||
from onyx.tools.utils import is_image_generation_available
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.telemetry import create_milestone_and_report
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
admin_router = APIRouter(prefix="/admin/persona")
|
||||
basic_router = APIRouter(prefix="/persona")
|
||||
|
||||
@@ -209,7 +210,6 @@ def create_persona(
|
||||
and len(persona_upsert_request.prompt_ids) > 0
|
||||
else None
|
||||
)
|
||||
|
||||
prompt = upsert_prompt(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
|
||||
@@ -85,8 +85,6 @@ class PersonaUpsertRequest(BaseModel):
|
||||
label_ids: list[int] | None = None
|
||||
is_default_persona: bool = False
|
||||
display_priority: int | None = None
|
||||
user_file_ids: list[int] | None = None
|
||||
user_folder_ids: list[int] | None = None
|
||||
|
||||
|
||||
class PersonaSnapshot(BaseModel):
|
||||
|
||||
@@ -4,7 +4,6 @@ from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
|
||||
from onyx.llm.llm_provider_options import fetch_models_for_provider
|
||||
from onyx.llm.utils import get_max_input_tokens
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -36,36 +35,22 @@ class LLMProviderDescriptor(BaseModel):
|
||||
fast_default_model_name: str | None
|
||||
is_default_provider: bool | None
|
||||
display_model_names: list[str] | None
|
||||
model_token_limits: dict[str, int] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_model(
|
||||
cls, llm_provider_model: "LLMProviderModel"
|
||||
) -> "LLMProviderDescriptor":
|
||||
model_names = (
|
||||
llm_provider_model.model_names
|
||||
or fetch_models_for_provider(llm_provider_model.provider)
|
||||
or [llm_provider_model.default_model_name]
|
||||
)
|
||||
|
||||
model_token_rate = (
|
||||
{
|
||||
model_name: get_max_input_tokens(
|
||||
model_name, llm_provider_model.provider
|
||||
)
|
||||
for model_name in model_names
|
||||
}
|
||||
if model_names is not None
|
||||
else None
|
||||
)
|
||||
return cls(
|
||||
name=llm_provider_model.name,
|
||||
provider=llm_provider_model.provider,
|
||||
default_model_name=llm_provider_model.default_model_name,
|
||||
fast_default_model_name=llm_provider_model.fast_default_model_name,
|
||||
is_default_provider=llm_provider_model.is_default_provider,
|
||||
model_names=model_names,
|
||||
model_token_limits=model_token_rate,
|
||||
model_names=(
|
||||
llm_provider_model.model_names
|
||||
or fetch_models_for_provider(llm_provider_model.provider)
|
||||
or [llm_provider_model.default_model_name]
|
||||
),
|
||||
display_model_names=llm_provider_model.display_model_names,
|
||||
)
|
||||
|
||||
@@ -95,7 +80,6 @@ class FullLLMProvider(LLMProvider):
|
||||
id: int
|
||||
is_default_provider: bool | None = None
|
||||
model_names: list[str]
|
||||
model_token_limits: dict[str, int] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, llm_provider_model: "LLMProviderModel") -> "FullLLMProvider":
|
||||
@@ -116,14 +100,6 @@ class FullLLMProvider(LLMProvider):
|
||||
or fetch_models_for_provider(llm_provider_model.provider)
|
||||
or [llm_provider_model.default_model_name]
|
||||
),
|
||||
model_token_limits={
|
||||
model_name: get_max_input_tokens(
|
||||
model_name, llm_provider_model.provider
|
||||
)
|
||||
for model_name in llm_provider_model.model_names
|
||||
}
|
||||
if llm_provider_model.model_names is not None
|
||||
else None,
|
||||
is_public=llm_provider_model.is_public,
|
||||
groups=[group.id for group in llm_provider_model.groups],
|
||||
deployment_name=llm_provider_model.deployment_name,
|
||||
|
||||
@@ -343,7 +343,8 @@ def list_bot_configs(
|
||||
]
|
||||
|
||||
|
||||
MAX_CHANNELS = 200
|
||||
MAX_SLACK_PAGES = 5
|
||||
SLACK_API_CHANNELS_PER_PAGE = 100
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -355,8 +356,8 @@ def get_all_channels_from_slack_api(
|
||||
_: User | None = Depends(current_admin_user),
|
||||
) -> list[SlackChannel]:
|
||||
"""
|
||||
Fetches all channels from the Slack API.
|
||||
If the workspace has 200 or more channels, we raise an error.
|
||||
Fetches channels the bot is a member of from the Slack API.
|
||||
Handles pagination with a limit to avoid excessive API calls.
|
||||
"""
|
||||
tokens = fetch_slack_bot_tokens(db_session, bot_id)
|
||||
if not tokens or "bot_token" not in tokens:
|
||||
@@ -365,28 +366,60 @@ def get_all_channels_from_slack_api(
|
||||
)
|
||||
|
||||
client = WebClient(token=tokens["bot_token"])
|
||||
all_channels = []
|
||||
next_cursor = None
|
||||
current_page = 0
|
||||
|
||||
try:
|
||||
response = client.conversations_list(
|
||||
types="public_channel,private_channel",
|
||||
exclude_archived=True,
|
||||
limit=MAX_CHANNELS,
|
||||
)
|
||||
# Use users_conversations with limited pagination
|
||||
while current_page < MAX_SLACK_PAGES:
|
||||
current_page += 1
|
||||
|
||||
# Make API call with cursor if we have one
|
||||
if next_cursor:
|
||||
response = client.users_conversations(
|
||||
types="public_channel,private_channel",
|
||||
exclude_archived=True,
|
||||
cursor=next_cursor,
|
||||
limit=SLACK_API_CHANNELS_PER_PAGE,
|
||||
)
|
||||
else:
|
||||
response = client.users_conversations(
|
||||
types="public_channel,private_channel",
|
||||
exclude_archived=True,
|
||||
limit=SLACK_API_CHANNELS_PER_PAGE,
|
||||
)
|
||||
|
||||
# Add channels to our list
|
||||
if "channels" in response and response["channels"]:
|
||||
all_channels.extend(response["channels"])
|
||||
|
||||
# Check if we need to paginate
|
||||
if (
|
||||
"response_metadata" in response
|
||||
and "next_cursor" in response["response_metadata"]
|
||||
):
|
||||
next_cursor = response["response_metadata"]["next_cursor"]
|
||||
if next_cursor:
|
||||
if current_page == MAX_SLACK_PAGES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Workspace has too many channels to paginate over in this call.",
|
||||
)
|
||||
continue
|
||||
|
||||
# If we get here, no more pages
|
||||
break
|
||||
|
||||
channels = [
|
||||
SlackChannel(id=channel["id"], name=channel["name"])
|
||||
for channel in response["channels"]
|
||||
for channel in all_channels
|
||||
]
|
||||
|
||||
if len(channels) == MAX_CHANNELS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Workspace has {MAX_CHANNELS} or more channels.",
|
||||
)
|
||||
|
||||
return channels
|
||||
|
||||
except SlackApiError as e:
|
||||
# Handle rate limiting or other API errors
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error fetching channels from Slack API: {str(e)}",
|
||||
|
||||
@@ -147,9 +147,11 @@ def list_threads(
|
||||
name=chat.description,
|
||||
persona_id=chat.persona_id,
|
||||
time_created=chat.time_created.isoformat(),
|
||||
time_updated=chat.time_updated.isoformat(),
|
||||
shared_status=chat.shared_status,
|
||||
folder_id=chat.folder_id,
|
||||
current_alternate_model=chat.current_alternate_model,
|
||||
current_temperature_override=chat.temperature_override,
|
||||
)
|
||||
for chat in chat_sessions
|
||||
]
|
||||
|
||||
@@ -3,7 +3,6 @@ import datetime
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
@@ -30,12 +29,10 @@ from onyx.chat.prompt_builder.citations_prompt import (
|
||||
compute_max_document_tokens_for_persona,
|
||||
)
|
||||
from onyx.configs.app_configs import WEB_DOMAIN
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import FileOrigin
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.configs.constants import MilestoneRecordType
|
||||
from onyx.configs.model_configs import LITELLM_PASS_THROUGH_HEADERS
|
||||
from onyx.connectors.models import InputType
|
||||
from onyx.db.chat import add_chats_to_session_from_slack_thread
|
||||
from onyx.db.chat import create_chat_session
|
||||
from onyx.db.chat import create_new_chat_message
|
||||
@@ -50,18 +47,13 @@ from onyx.db.chat import get_or_create_root_message
|
||||
from onyx.db.chat import set_as_latest_chat_message
|
||||
from onyx.db.chat import translate_db_message_to_chat_message_detail
|
||||
from onyx.db.chat import update_chat_session
|
||||
from onyx.db.connector import create_connector
|
||||
from onyx.db.connector_credential_pair import add_credential_to_connector
|
||||
from onyx.db.credentials import create_credential
|
||||
from onyx.db.chat_search import search_chat_sessions
|
||||
from onyx.db.engine import get_session
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.enums import AccessType
|
||||
from onyx.db.feedback import create_chat_message_feedback
|
||||
from onyx.db.feedback import create_doc_retrieval_feedback
|
||||
from onyx.db.models import User
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
from onyx.db.user_documents import create_user_files
|
||||
from onyx.file_processing.extract_file_text import docx_to_txt_filename
|
||||
from onyx.file_processing.extract_file_text import extract_file_text
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
@@ -74,8 +66,6 @@ from onyx.natural_language_processing.utils import get_tokenizer
|
||||
from onyx.secondary_llm_flows.chat_session_naming import (
|
||||
get_renamed_conversation_name,
|
||||
)
|
||||
from onyx.server.documents.models import ConnectorBase
|
||||
from onyx.server.documents.models import CredentialBase
|
||||
from onyx.server.query_and_chat.models import ChatFeedbackRequest
|
||||
from onyx.server.query_and_chat.models import ChatMessageIdentifier
|
||||
from onyx.server.query_and_chat.models import ChatRenameRequest
|
||||
@@ -101,7 +91,6 @@ from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.telemetry import create_milestone_and_report
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
RECENT_DOCS_FOLDER_ID = -1
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -130,6 +119,7 @@ def get_user_chat_sessions(
|
||||
name=chat.description,
|
||||
persona_id=chat.persona_id,
|
||||
time_created=chat.time_created.isoformat(),
|
||||
time_updated=chat.time_updated.isoformat(),
|
||||
shared_status=chat.shared_status,
|
||||
folder_id=chat.folder_id,
|
||||
current_alternate_model=chat.current_alternate_model,
|
||||
@@ -658,7 +648,7 @@ def seed_chat_from_slack(
|
||||
def upload_files_for_chat(
|
||||
files: list[UploadFile],
|
||||
db_session: Session = Depends(get_session),
|
||||
user: User | None = Depends(current_user),
|
||||
_: User | None = Depends(current_user),
|
||||
) -> dict[str, list[FileDescriptor]]:
|
||||
image_content_types = {"image/jpeg", "image/png", "image/webp"}
|
||||
csv_content_types = {"text/csv"}
|
||||
@@ -696,11 +686,17 @@ def upload_files_for_chat(
|
||||
if file.content_type in image_content_types:
|
||||
error_detail = "Unsupported image file type. Supported image types include .jpg, .jpeg, .png, .webp."
|
||||
elif file.content_type in text_content_types:
|
||||
error_detail = "Unsupported text file type."
|
||||
error_detail = "Unsupported text file type. Supported text types include .txt, .csv, .md, .mdx, .conf, "
|
||||
".log, .tsv."
|
||||
elif file.content_type in csv_content_types:
|
||||
error_detail = "Unsupported CSV file type."
|
||||
error_detail = (
|
||||
"Unsupported CSV file type. Supported CSV types include .csv."
|
||||
)
|
||||
else:
|
||||
error_detail = "Unsupported document file type."
|
||||
error_detail = (
|
||||
"Unsupported document file type. Supported document types include .pdf, .docx, .pptx, .xlsx, "
|
||||
".json, .xml, .yml, .yaml, .eml, .epub."
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=error_detail)
|
||||
|
||||
if (
|
||||
@@ -748,12 +744,11 @@ def upload_files_for_chat(
|
||||
file_type=new_content_type or file_type.value,
|
||||
)
|
||||
|
||||
# 4) If the file is a doc, extract text and store that separately
|
||||
# if the file is a doc, extract text and store that so we don't need
|
||||
# to re-extract it every time we send a message
|
||||
if file_type == ChatFileType.DOC:
|
||||
# Re-wrap bytes in a fresh BytesIO so we start at position 0
|
||||
extracted_text_io = io.BytesIO(file_content)
|
||||
extracted_text = extract_file_text(
|
||||
file=extracted_text_io, # use the bytes we already read
|
||||
file=file_content_io, # use the bytes we already read
|
||||
file_name=file.filename or "",
|
||||
)
|
||||
text_file_id = str(uuid.uuid4())
|
||||
@@ -765,57 +760,13 @@ def upload_files_for_chat(
|
||||
file_origin=FileOrigin.CHAT_UPLOAD,
|
||||
file_type="text/plain",
|
||||
)
|
||||
# Return the text file as the "main" file descriptor for doc types
|
||||
# for DOC type, just return this for the FileDescriptor
|
||||
# as we would always use this as the ID to attach to the
|
||||
# message
|
||||
file_info.append((text_file_id, file.filename, ChatFileType.PLAIN_TEXT))
|
||||
else:
|
||||
file_info.append((file_id, file.filename, file_type))
|
||||
|
||||
# 5) Create a user file for each uploaded file
|
||||
user_files = create_user_files([file], RECENT_DOCS_FOLDER_ID, user, db_session)
|
||||
for user_file in user_files:
|
||||
# 6) Create connector
|
||||
connector_base = ConnectorBase(
|
||||
name=f"UserFile-{int(time.time())}",
|
||||
source=DocumentSource.FILE,
|
||||
input_type=InputType.LOAD_STATE,
|
||||
connector_specific_config={
|
||||
"file_locations": [user_file.file_id],
|
||||
},
|
||||
refresh_freq=None,
|
||||
prune_freq=None,
|
||||
indexing_start=None,
|
||||
)
|
||||
connector = create_connector(
|
||||
db_session=db_session,
|
||||
connector_data=connector_base,
|
||||
)
|
||||
|
||||
# 7) Create credential
|
||||
credential_info = CredentialBase(
|
||||
credential_json={},
|
||||
admin_public=True,
|
||||
source=DocumentSource.FILE,
|
||||
curator_public=True,
|
||||
groups=[],
|
||||
name=f"UserFileCredential-{int(time.time())}",
|
||||
is_user_file=True,
|
||||
)
|
||||
credential = create_credential(credential_info, user, db_session)
|
||||
|
||||
# 8) Create connector credential pair
|
||||
cc_pair = add_credential_to_connector(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
connector_id=connector.id,
|
||||
credential_id=credential.id,
|
||||
cc_pair_name=f"UserFileCCPair-{int(time.time())}",
|
||||
access_type=AccessType.PRIVATE,
|
||||
auto_sync_options=None,
|
||||
groups=[],
|
||||
)
|
||||
user_file.cc_pair_id = cc_pair.data
|
||||
db_session.commit()
|
||||
|
||||
return {
|
||||
"files": [
|
||||
{"id": file_id, "type": file_type, "name": file_name}
|
||||
|
||||
@@ -92,8 +92,6 @@ class CreateChatMessageRequest(ChunkContext):
|
||||
message: str
|
||||
# Files that we should attach to this message
|
||||
file_descriptors: list[FileDescriptor]
|
||||
user_file_ids: list[int] = []
|
||||
user_folder_ids: list[int] = []
|
||||
|
||||
# If no prompt provided, uses the largest prompt of the chat session
|
||||
# but really this should be explicitly specified, only in the simplified APIs is this inferred
|
||||
@@ -120,7 +118,7 @@ class CreateChatMessageRequest(ChunkContext):
|
||||
# this does persist in the chat thread details
|
||||
temperature_override: float | None = None
|
||||
|
||||
# allow user to specify an alternate assistant
|
||||
# allow user to specify an alternate assistnat
|
||||
alternate_assistant_id: int | None = None
|
||||
|
||||
# This takes the priority over the prompt_override
|
||||
@@ -137,8 +135,6 @@ class CreateChatMessageRequest(ChunkContext):
|
||||
# https://platform.openai.com/docs/guides/structured-outputs/introduction
|
||||
structured_response_format: dict | None = None
|
||||
|
||||
force_user_file_search: bool = False
|
||||
|
||||
# If true, ignores most of the search options and uses pro search instead.
|
||||
# TODO: decide how many of the above options we want to pass through to pro search
|
||||
use_agentic_search: bool = False
|
||||
@@ -185,6 +181,7 @@ class ChatSessionDetails(BaseModel):
|
||||
name: str | None
|
||||
persona_id: int | None = None
|
||||
time_created: str
|
||||
time_updated: str
|
||||
shared_status: ChatSessionSharedStatus
|
||||
folder_id: int | None = None
|
||||
current_alternate_model: str | None = None
|
||||
@@ -245,6 +242,7 @@ class ChatMessageDetail(BaseModel):
|
||||
files: list[FileDescriptor]
|
||||
tool_call: ToolCallFinalResult | None
|
||||
refined_answer_improvement: bool | None = None
|
||||
is_agentic: bool | None = None
|
||||
error: str | None = None
|
||||
|
||||
def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore
|
||||
|
||||
@@ -159,6 +159,7 @@ def get_user_search_sessions(
|
||||
name=sessions_with_documents_dict[search.id],
|
||||
persona_id=search.persona_id,
|
||||
time_created=search.time_created.isoformat(),
|
||||
time_updated=search.time_updated.isoformat(),
|
||||
shared_status=search.shared_status,
|
||||
folder_id=search.folder_id,
|
||||
current_alternate_model=search.current_alternate_model,
|
||||
|
||||
@@ -4,6 +4,7 @@ from enum import Enum
|
||||
from pydantic import BaseModel
|
||||
|
||||
from onyx.configs.constants import NotificationType
|
||||
from onyx.configs.constants import QueryHistoryType
|
||||
from onyx.db.models import Notification as NotificationDBModel
|
||||
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
|
||||
|
||||
@@ -50,6 +51,7 @@ class Settings(BaseModel):
|
||||
|
||||
temperature_override_enabled: bool | None = False
|
||||
auto_scroll: bool | None = False
|
||||
query_history_type: QueryHistoryType | None = None
|
||||
|
||||
|
||||
class UserSettings(Settings):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from onyx.configs.app_configs import ONYX_QUERY_HISTORY_TYPE
|
||||
from onyx.configs.constants import KV_SETTINGS_KEY
|
||||
from onyx.configs.constants import OnyxRedisLocks
|
||||
from onyx.key_value_store.factory import get_kv_store
|
||||
@@ -45,6 +46,7 @@ def load_settings() -> Settings:
|
||||
anonymous_user_enabled = False
|
||||
|
||||
settings.anonymous_user_enabled = anonymous_user_enabled
|
||||
settings.query_history_type = ONYX_QUERY_HISTORY_TYPE
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
import io
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
import sqlalchemy.exc
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import File
|
||||
from fastapi import Form
|
||||
from fastapi import HTTPException
|
||||
from fastapi import UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.models import InputType
|
||||
from onyx.db.connector import create_connector
|
||||
from onyx.db.connector_credential_pair import add_credential_to_connector
|
||||
from onyx.db.credentials import create_credential
|
||||
from onyx.db.engine import get_session
|
||||
from onyx.db.enums import AccessType
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import UserFile
|
||||
from onyx.db.models import UserFolder
|
||||
from onyx.db.user_documents import create_user_files
|
||||
from onyx.db.user_documents import share_file_with_assistant
|
||||
from onyx.db.user_documents import share_folder_with_assistant
|
||||
from onyx.db.user_documents import unshare_file_with_assistant
|
||||
from onyx.db.user_documents import unshare_folder_with_assistant
|
||||
from onyx.file_processing.html_utils import web_html_cleanup
|
||||
from onyx.server.documents.models import ConnectorBase
|
||||
from onyx.server.documents.models import CredentialBase
|
||||
from onyx.server.documents.models import FileUploadResponse
|
||||
from onyx.server.user_documents.models import MessageResponse
|
||||
from onyx.server.user_documents.models import UserFileSnapshot
|
||||
from onyx.server.user_documents.models import UserFolderSnapshot
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FolderCreationRequest(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
@router.post("/user/folder")
|
||||
def create_folder(
|
||||
request: FolderCreationRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserFolderSnapshot:
|
||||
try:
|
||||
new_folder = UserFolder(
|
||||
user_id=user.id if user else None,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
)
|
||||
db_session.add(new_folder)
|
||||
db_session.commit()
|
||||
return UserFolderSnapshot.from_model(new_folder)
|
||||
except sqlalchemy.exc.DataError as e:
|
||||
if "StringDataRightTruncation" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Folder name or description is too long. Please use a shorter name or description.",
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user/folder",
|
||||
)
|
||||
def get_folders(
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[UserFolderSnapshot]:
|
||||
user_id = user.id if user else None
|
||||
folders = db_session.query(UserFolder).filter(UserFolder.user_id == user_id).all()
|
||||
return [UserFolderSnapshot.from_model(folder) for folder in folders]
|
||||
|
||||
|
||||
@router.get("/user/folder/{folder_id}")
|
||||
def get_folder(
|
||||
folder_id: int,
|
||||
user: User | None = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserFolderSnapshot:
|
||||
user_id = user.id if user else None
|
||||
folder = (
|
||||
db_session.query(UserFolder)
|
||||
.filter(UserFolder.id == folder_id, UserFolder.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
return UserFolderSnapshot.from_model(folder)
|
||||
|
||||
|
||||
RECENT_DOCS_FOLDER_ID = -1
|
||||
|
||||
|
||||
@router.post("/user/file/upload")
|
||||
def upload_user_files(
|
||||
files: List[UploadFile] = File(...),
|
||||
folder_id: int | None = Form(None),
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> FileUploadResponse:
|
||||
if folder_id == 0:
|
||||
folder_id = None
|
||||
|
||||
user_files = create_user_files(files, folder_id, user, db_session)
|
||||
for user_file in user_files:
|
||||
connector_base = ConnectorBase(
|
||||
name=f"UserFile-{user_file.file_id}-{int(time.time())}",
|
||||
source=DocumentSource.FILE,
|
||||
input_type=InputType.LOAD_STATE,
|
||||
connector_specific_config={
|
||||
"file_locations": [user_file.file_id],
|
||||
},
|
||||
refresh_freq=None,
|
||||
prune_freq=None,
|
||||
indexing_start=None,
|
||||
)
|
||||
|
||||
connector = create_connector(
|
||||
db_session=db_session,
|
||||
connector_data=connector_base,
|
||||
)
|
||||
|
||||
credential_info = CredentialBase(
|
||||
credential_json={},
|
||||
admin_public=True,
|
||||
source=DocumentSource.FILE,
|
||||
curator_public=True,
|
||||
groups=[],
|
||||
name=f"UserFileCredential-{user_file.file_id}-{int(time.time())}",
|
||||
is_user_file=True,
|
||||
)
|
||||
credential = create_credential(credential_info, user, db_session)
|
||||
|
||||
cc_pair = add_credential_to_connector(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
connector_id=connector.id,
|
||||
credential_id=credential.id,
|
||||
cc_pair_name=f"UserFileCCPair-{user_file.file_id}-{int(time.time())}",
|
||||
access_type=AccessType.PRIVATE,
|
||||
auto_sync_options=None,
|
||||
groups=[],
|
||||
is_user_file=True,
|
||||
)
|
||||
user_file.cc_pair_id = cc_pair.data
|
||||
print("A")
|
||||
db_session.commit()
|
||||
|
||||
db_session.commit()
|
||||
# TODO: functional document indexing
|
||||
# trigger_document_indexing(db_session, user.id)
|
||||
return FileUploadResponse(
|
||||
file_paths=[user_file.file_id for user_file in user_files],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/user/folder/{folder_id}")
|
||||
def update_folder(
|
||||
folder_id: int,
|
||||
name: str,
|
||||
user: User | None = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserFolderSnapshot:
|
||||
user_id = user.id if user else None
|
||||
folder = (
|
||||
db_session.query(UserFolder)
|
||||
.filter(UserFolder.id == folder_id, UserFolder.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
folder.name = name
|
||||
|
||||
db_session.commit()
|
||||
|
||||
return UserFolderSnapshot.from_model(folder)
|
||||
|
||||
|
||||
@router.delete("/user/folder/{folder_id}")
|
||||
def delete_folder(
|
||||
folder_id: int,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
user_id = user.id if user else None
|
||||
folder = (
|
||||
db_session.query(UserFolder)
|
||||
.filter(UserFolder.id == folder_id, UserFolder.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
db_session.delete(folder)
|
||||
db_session.commit()
|
||||
return MessageResponse(message="Folder deleted successfully")
|
||||
|
||||
|
||||
@router.delete("/user/file/{file_id}")
|
||||
def delete_file(
|
||||
file_id: int,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
user_id = user.id if user else None
|
||||
file = (
|
||||
db_session.query(UserFile)
|
||||
.filter(UserFile.id == file_id, UserFile.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
db_session.delete(file)
|
||||
db_session.commit()
|
||||
return MessageResponse(message="File deleted successfully")
|
||||
|
||||
|
||||
class FileMoveRequest(BaseModel):
|
||||
new_folder_id: int | None
|
||||
|
||||
|
||||
@router.put("/user/file/{file_id}/move")
|
||||
def move_file(
|
||||
file_id: int,
|
||||
request: FileMoveRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserFileSnapshot:
|
||||
user_id = user.id if user else None
|
||||
file = (
|
||||
db_session.query(UserFile)
|
||||
.filter(UserFile.id == file_id, UserFile.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file.folder_id = request.new_folder_id
|
||||
db_session.commit()
|
||||
return UserFileSnapshot.from_model(file)
|
||||
|
||||
|
||||
@router.get("/user/file-system")
|
||||
def get_file_system(
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[UserFolderSnapshot]:
|
||||
user_id = user.id if user else None
|
||||
folders = db_session.query(UserFolder).filter(UserFolder.user_id == user_id).all()
|
||||
return [UserFolderSnapshot.from_model(folder) for folder in folders]
|
||||
|
||||
|
||||
@router.put("/user/file/{file_id}/rename")
|
||||
def rename_file(
|
||||
file_id: int,
|
||||
name: str,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserFileSnapshot:
|
||||
user_id = user.id if user else None
|
||||
file = (
|
||||
db_session.query(UserFile)
|
||||
.filter(UserFile.id == file_id, UserFile.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file.name = name
|
||||
db_session.commit()
|
||||
return UserFileSnapshot.from_model(file)
|
||||
|
||||
|
||||
class ShareRequest(BaseModel):
|
||||
assistant_id: int
|
||||
|
||||
|
||||
@router.post("/user/file/{file_id}/share")
|
||||
def share_file(
|
||||
file_id: int,
|
||||
request: ShareRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
user_id = user.id if user else None
|
||||
file = (
|
||||
db_session.query(UserFile)
|
||||
.filter(UserFile.id == file_id, UserFile.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
share_file_with_assistant(file_id, request.assistant_id, db_session)
|
||||
return MessageResponse(message="File shared successfully with the assistant")
|
||||
|
||||
|
||||
@router.post("/user/file/{file_id}/unshare")
|
||||
def unshare_file(
|
||||
file_id: int,
|
||||
request: ShareRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
user_id = user.id if user else None
|
||||
file = (
|
||||
db_session.query(UserFile)
|
||||
.filter(UserFile.id == file_id, UserFile.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
unshare_file_with_assistant(file_id, request.assistant_id, db_session)
|
||||
return MessageResponse(message="File unshared successfully from the assistant")
|
||||
|
||||
|
||||
@router.post("/user/folder/{folder_id}/share")
|
||||
def share_folder(
|
||||
folder_id: int,
|
||||
request: ShareRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
user_id = user.id if user else None
|
||||
folder = (
|
||||
db_session.query(UserFolder)
|
||||
.filter(UserFolder.id == folder_id, UserFolder.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
share_folder_with_assistant(folder_id, request.assistant_id, db_session)
|
||||
return MessageResponse(
|
||||
message="Folder and its files shared successfully with the assistant"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/user/folder/{folder_id}/unshare")
|
||||
def unshare_folder(
|
||||
folder_id: int,
|
||||
request: ShareRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
user_id = user.id if user else None
|
||||
folder = (
|
||||
db_session.query(UserFolder)
|
||||
.filter(UserFolder.id == folder_id, UserFolder.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
unshare_folder_with_assistant(folder_id, request.assistant_id, db_session)
|
||||
return MessageResponse(
|
||||
message="Folder and its files unshared successfully from the assistant"
|
||||
)
|
||||
|
||||
|
||||
class CreateFileFromLinkRequest(BaseModel):
|
||||
url: str
|
||||
folder_id: int | None
|
||||
|
||||
|
||||
@router.post("/user/file/create-from-link")
|
||||
def create_file_from_link(
|
||||
request: CreateFileFromLinkRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> FileUploadResponse:
|
||||
try:
|
||||
response = requests.get(request.url)
|
||||
response.raise_for_status()
|
||||
content = response.text
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
parsed_html = web_html_cleanup(soup, mintlify_cleanup_enabled=False)
|
||||
|
||||
file_name = f"{parsed_html.title or 'Untitled'}.txt"
|
||||
file_content = parsed_html.cleaned_text.encode()
|
||||
|
||||
file = UploadFile(filename=file_name, file=io.BytesIO(file_content))
|
||||
user_files = create_user_files([file], request.folder_id, user, db_session)
|
||||
|
||||
# Create connector and credential (same as in upload_user_files)
|
||||
for user_file in user_files:
|
||||
connector_base = ConnectorBase(
|
||||
name=f"UserFile-{user_file.file_id}-{int(time.time())}",
|
||||
source=DocumentSource.FILE,
|
||||
input_type=InputType.LOAD_STATE,
|
||||
connector_specific_config={
|
||||
"file_locations": [user_file.file_id],
|
||||
},
|
||||
refresh_freq=None,
|
||||
prune_freq=None,
|
||||
indexing_start=None,
|
||||
)
|
||||
|
||||
connector = create_connector(
|
||||
db_session=db_session,
|
||||
connector_data=connector_base,
|
||||
)
|
||||
|
||||
credential_info = CredentialBase(
|
||||
credential_json={},
|
||||
admin_public=True,
|
||||
source=DocumentSource.FILE,
|
||||
curator_public=True,
|
||||
groups=[],
|
||||
name=f"UserFileCredential-{user_file.file_id}-{int(time.time())}",
|
||||
)
|
||||
credential = create_credential(credential_info, user, db_session)
|
||||
|
||||
cc_pair = add_credential_to_connector(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
connector_id=connector.id,
|
||||
credential_id=credential.id,
|
||||
cc_pair_name=f"UserFileCCPair-{int(time.time())}",
|
||||
access_type=AccessType.PRIVATE,
|
||||
auto_sync_options=None,
|
||||
groups=[],
|
||||
is_user_file=True,
|
||||
)
|
||||
user_file.cc_pair_id = cc_pair.data
|
||||
db_session.commit()
|
||||
|
||||
db_session.commit()
|
||||
return FileUploadResponse(
|
||||
file_paths=[user_file.file_id for user_file in user_files]
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch URL: {str(e)}")
|
||||
@@ -1,70 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from onyx.db.models import UserFile
|
||||
from onyx.db.models import UserFolder
|
||||
|
||||
|
||||
class UserFileSnapshot(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
document_id: str
|
||||
folder_id: int | None = None
|
||||
user_id: int | None
|
||||
file_id: str
|
||||
created_at: datetime
|
||||
assistant_ids: List[int] = [] # List of assistant IDs
|
||||
token_count: int | None
|
||||
indexed: bool
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, model: UserFile) -> "UserFileSnapshot":
|
||||
return cls(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
folder_id=model.folder_id,
|
||||
document_id=model.document_id,
|
||||
user_id=model.user_id,
|
||||
file_id=model.file_id,
|
||||
created_at=model.created_at,
|
||||
assistant_ids=[assistant.id for assistant in model.assistants],
|
||||
token_count=model.token_count,
|
||||
indexed=model.cc_pair.last_successful_index_time is not None
|
||||
if model.cc_pair
|
||||
else False,
|
||||
)
|
||||
|
||||
|
||||
class UserFolderSnapshot(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
files: List[UserFileSnapshot]
|
||||
created_at: datetime
|
||||
user_id: int | None
|
||||
assistant_ids: List[int] = [] # List of assistant IDs
|
||||
token_count: int | None
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, model: UserFolder) -> "UserFolderSnapshot":
|
||||
return cls(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
description=model.description,
|
||||
files=[UserFileSnapshot.from_model(file) for file in model.files],
|
||||
created_at=model.created_at,
|
||||
user_id=model.user_id,
|
||||
assistant_ids=[assistant.id for assistant in model.assistants],
|
||||
token_count=sum(file.token_count or 0 for file in model.files) or None,
|
||||
)
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class FileSystemResponse(BaseModel):
|
||||
folders: list[UserFolderSnapshot]
|
||||
files: list[UserFileSnapshot]
|
||||
@@ -138,7 +138,6 @@ def construct_tools(
|
||||
user: User | None,
|
||||
llm: LLM,
|
||||
fast_llm: LLM,
|
||||
use_file_search: bool,
|
||||
search_tool_config: SearchToolConfig | None = None,
|
||||
internet_search_tool_config: InternetSearchToolConfig | None = None,
|
||||
image_generation_tool_config: ImageGenerationToolConfig | None = None,
|
||||
@@ -252,33 +251,6 @@ def construct_tools(
|
||||
for tool_list in tool_dict.values():
|
||||
tools.extend(tool_list)
|
||||
|
||||
if use_file_search:
|
||||
search_tool_config = SearchToolConfig()
|
||||
|
||||
search_tool = SearchTool(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
persona=persona,
|
||||
retrieval_options=search_tool_config.retrieval_options,
|
||||
prompt_config=prompt_config,
|
||||
llm=llm,
|
||||
fast_llm=fast_llm,
|
||||
pruning_config=search_tool_config.document_pruning_config,
|
||||
answer_style_config=search_tool_config.answer_style_config,
|
||||
selected_sections=search_tool_config.selected_sections,
|
||||
chunks_above=search_tool_config.chunks_above,
|
||||
chunks_below=search_tool_config.chunks_below,
|
||||
full_doc=search_tool_config.full_doc,
|
||||
evaluation_type=(
|
||||
LLMEvaluationType.BASIC
|
||||
if persona.llm_relevance_filter
|
||||
else LLMEvaluationType.SKIP
|
||||
),
|
||||
rerank_settings=search_tool_config.rerank_settings,
|
||||
bypass_acl=search_tool_config.bypass_acl,
|
||||
)
|
||||
tool_dict[1] = [search_tool]
|
||||
|
||||
# factor in tool definition size when pruning
|
||||
if search_tool_config:
|
||||
search_tool_config.document_pruning_config.tool_num_tokens = (
|
||||
|
||||
@@ -64,7 +64,7 @@ logger = setup_logger()
|
||||
CUSTOM_TOOL_RESPONSE_ID = "custom_tool_response"
|
||||
|
||||
|
||||
class CustomToolUserFileSnapshot(BaseModel):
|
||||
class CustomToolFileResponse(BaseModel):
|
||||
file_ids: List[str] # References to saved images or CSVs
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class CustomTool(BaseTool):
|
||||
response = cast(CustomToolCallSummary, args[0].response)
|
||||
|
||||
if response.response_type == "image" or response.response_type == "csv":
|
||||
image_response = cast(CustomToolUserFileSnapshot, response.tool_result)
|
||||
image_response = cast(CustomToolFileResponse, response.tool_result)
|
||||
return json.dumps({"file_ids": image_response.file_ids})
|
||||
|
||||
# For JSON or other responses, return as-is
|
||||
@@ -267,14 +267,14 @@ class CustomTool(BaseTool):
|
||||
file_ids = self._save_and_get_file_references(
|
||||
response.content, content_type
|
||||
)
|
||||
tool_result = CustomToolUserFileSnapshot(file_ids=file_ids)
|
||||
tool_result = CustomToolFileResponse(file_ids=file_ids)
|
||||
response_type = "csv"
|
||||
|
||||
elif "image/" in content_type:
|
||||
file_ids = self._save_and_get_file_references(
|
||||
response.content, content_type
|
||||
)
|
||||
tool_result = CustomToolUserFileSnapshot(file_ids=file_ids)
|
||||
tool_result = CustomToolFileResponse(file_ids=file_ids)
|
||||
response_type = "image"
|
||||
|
||||
else:
|
||||
@@ -358,7 +358,7 @@ class CustomTool(BaseTool):
|
||||
|
||||
def final_result(self, *args: ToolResponse) -> JSON_ro:
|
||||
response = cast(CustomToolCallSummary, args[0].response)
|
||||
if isinstance(response.tool_result, CustomToolUserFileSnapshot):
|
||||
if isinstance(response.tool_result, CustomToolFileResponse):
|
||||
return response.tool_result.model_dump()
|
||||
return response.tool_result
|
||||
|
||||
|
||||
@@ -444,15 +444,12 @@ def get_document_acls(
|
||||
response = vespa_client.get(document_url)
|
||||
if response.status_code == 200:
|
||||
fields = response.json().get("fields", {})
|
||||
|
||||
document_id = fields.get("document_id") or fields.get(
|
||||
"documentid", "Unknown"
|
||||
)
|
||||
acls = fields.get("access_control_list", {})
|
||||
title = fields.get("title", "")
|
||||
source_type = fields.get("source_type", "")
|
||||
doc_sets = fields.get("document_sets", [])
|
||||
user_file = fields.get("user_file", None)
|
||||
source_links_raw = fields.get("source_links", "{}")
|
||||
try:
|
||||
source_links = json.loads(source_links_raw)
|
||||
@@ -465,8 +462,6 @@ def get_document_acls(
|
||||
print(f"Source Links: {source_links}")
|
||||
print(f"Title: {title}")
|
||||
print(f"Source Type: {source_type}")
|
||||
print(f"Document Sets: {doc_sets}")
|
||||
print(f"User File: {user_file}")
|
||||
if MULTI_TENANT:
|
||||
print(f"Tenant ID: {fields.get('tenant_id', 'N/A')}")
|
||||
print("-" * 80)
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
image: onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: ../../backend
|
||||
is dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
alembic upgrade head &&
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
1154
web/package-lock.json
generated
1154
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,13 +20,11 @@
|
||||
"@phosphor-icons/react": "^2.0.8",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
|
||||
@@ -64,10 +64,10 @@ import { debounce } from "lodash";
|
||||
import { FullLLMProvider } from "../configuration/llm/interfaces";
|
||||
import StarterMessagesList from "./StarterMessageList";
|
||||
|
||||
import { SwitchField } from "@/components/ui/switch";
|
||||
import { Switch, SwitchField } from "@/components/ui/switch";
|
||||
import { generateIdenticon } from "@/components/assistants/AssistantIcon";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Checkbox, CheckboxField } from "@/components/ui/checkbox";
|
||||
import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
|
||||
import { MinimalUserSnapshot } from "@/lib/types";
|
||||
import { useUserGroups } from "@/lib/hooks";
|
||||
@@ -76,26 +76,12 @@ import {
|
||||
Option as DropdownOption,
|
||||
} from "@/components/Dropdown";
|
||||
import { SourceChip } from "@/app/chat/input/ChatInputBar";
|
||||
import {
|
||||
TagIcon,
|
||||
UserIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import { TagIcon, UserIcon, XIcon, InfoIcon } from "lucide-react";
|
||||
import { LLMSelector } from "@/components/llm/LLMSelector";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
|
||||
|
||||
import { FilePickerModal } from "@/app/chat/my-documents/components/FilePicker";
|
||||
import { useDocumentsContext } from "@/app/chat/my-documents/DocumentsContext";
|
||||
import {
|
||||
FileResponse,
|
||||
FolderResponse,
|
||||
} from "@/app/chat/my-documents/DocumentsContext";
|
||||
import { RadioGroup } from "@/components/ui/radio-group";
|
||||
import { RadioGroupItemField } from "@/components/ui/RadioGroupItemField";
|
||||
import Title from "@/components/ui/title";
|
||||
import { SEARCH_TOOL_ID } from "@/app/chat/tools/constants";
|
||||
|
||||
function findSearchTool(tools: ToolSnapshot[]) {
|
||||
@@ -161,7 +147,6 @@ export function AssistantEditor({
|
||||
"#6FFFFF",
|
||||
];
|
||||
|
||||
const [filePickerModalOpen, setFilePickerModalOpen] = useState(false);
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
|
||||
// state to persist across formik reformatting
|
||||
@@ -236,16 +221,6 @@ export function AssistantEditor({
|
||||
enabledToolsMap[tool.id] = personaCurrentToolIds.includes(tool.id);
|
||||
});
|
||||
|
||||
const {
|
||||
selectedFiles,
|
||||
selectedFolders,
|
||||
addSelectedFile,
|
||||
removeSelectedFile,
|
||||
addSelectedFolder,
|
||||
removeSelectedFolder,
|
||||
clearSelectedItems,
|
||||
} = useDocumentsContext();
|
||||
|
||||
const [showVisibilityWarning, setShowVisibilityWarning] = useState(false);
|
||||
|
||||
const initialValues = {
|
||||
@@ -284,9 +259,6 @@ export function AssistantEditor({
|
||||
(u) => u.id !== existingPersona.owner?.id
|
||||
) ?? [],
|
||||
selectedGroups: existingPersona?.groups ?? [],
|
||||
user_file_ids: existingPersona?.user_file_ids ?? [],
|
||||
user_folder_ids: existingPersona?.user_folder_ids ?? [],
|
||||
knowledge_source: "user_files",
|
||||
is_default_persona: existingPersona?.is_default_persona ?? false,
|
||||
};
|
||||
|
||||
@@ -396,24 +368,6 @@ export function AssistantEditor({
|
||||
<BackButton />
|
||||
</div>
|
||||
)}
|
||||
{filePickerModalOpen && (
|
||||
<FilePickerModal
|
||||
selectedFiles={selectedFiles}
|
||||
selectedFolders={selectedFolders}
|
||||
addSelectedFile={addSelectedFile}
|
||||
removeSelectedFile={removeSelectedFile}
|
||||
addSelectedFolder={addSelectedFolder}
|
||||
isOpen={filePickerModalOpen}
|
||||
onClose={() => {
|
||||
setFilePickerModalOpen(false);
|
||||
}}
|
||||
onSave={() => {
|
||||
setFilePickerModalOpen(false);
|
||||
}}
|
||||
title="Add Documents to your Assistant"
|
||||
buttonContent="Add to Assistant"
|
||||
/>
|
||||
)}
|
||||
|
||||
{labelToDelete && (
|
||||
<ConfirmEntityModal
|
||||
@@ -480,7 +434,6 @@ export function AssistantEditor({
|
||||
label_ids: Yup.array().of(Yup.number()),
|
||||
selectedUsers: Yup.array().of(Yup.object()),
|
||||
selectedGroups: Yup.array().of(Yup.number()),
|
||||
knowledge_source: Yup.string().required(),
|
||||
is_default_persona: Yup.boolean().required(),
|
||||
})
|
||||
.test(
|
||||
@@ -569,12 +522,9 @@ export function AssistantEditor({
|
||||
? new Date(values.search_start_date)
|
||||
: null,
|
||||
num_chunks: numChunks,
|
||||
user_file_ids: selectedFiles.map((file) => file.id),
|
||||
user_folder_ids: selectedFolders.map((folder) => folder.id),
|
||||
};
|
||||
|
||||
let personaResponse;
|
||||
|
||||
if (isUpdate) {
|
||||
personaResponse = await updatePersona(
|
||||
existingPersona.id,
|
||||
@@ -896,168 +846,77 @@ export function AssistantEditor({
|
||||
values.enabled_tools_map[searchTool.id] &&
|
||||
!(user?.role != "admin" && documentSets.length === 0) && (
|
||||
<CollapsibleSection>
|
||||
<div>
|
||||
<Label>Knowledge Source</Label>
|
||||
<RadioGroup
|
||||
className="flex flex-col gap-y-4 mt-2"
|
||||
value={values.knowledge_source}
|
||||
onValueChange={(value: string) => {
|
||||
setFieldValue("knowledge_source", value);
|
||||
}}
|
||||
>
|
||||
<RadioGroupItemField
|
||||
value="user_files"
|
||||
id="user_files"
|
||||
label="User Files"
|
||||
sublabel="Select specific user files and folders for this Assistant to use"
|
||||
/>
|
||||
<RadioGroupItemField
|
||||
value="team_knowledge"
|
||||
id="team_knowledge"
|
||||
label="Team Knowledge"
|
||||
sublabel="Use team-wide document sets for this Assistant"
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
{values.knowledge_source === "user_files" &&
|
||||
!existingPersona?.is_default_persona &&
|
||||
!admin && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-start gap-x-2 items-center">
|
||||
<Label>User Files</Label>
|
||||
<span
|
||||
className="cursor-pointer text-xs text-primary hover:underline"
|
||||
onClick={() => setFilePickerModalOpen(true)}
|
||||
>
|
||||
Attach Files and Folders
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{ccPairs.length > 0 && (
|
||||
<>
|
||||
<Label small>Document Sets</Label>
|
||||
<div>
|
||||
<SubLabel>
|
||||
Select which of your user files and folders
|
||||
this Assistant should use to inform its
|
||||
responses. If none are specified, the
|
||||
Assistant will not have access to any
|
||||
user-specific documents.
|
||||
<>
|
||||
Select which{" "}
|
||||
{!user || user.role === "admin" ? (
|
||||
<Link
|
||||
href="/admin/documents/sets"
|
||||
className="font-semibold underline hover:underline text-text"
|
||||
target="_blank"
|
||||
>
|
||||
Document Sets
|
||||
</Link>
|
||||
) : (
|
||||
"Document Sets"
|
||||
)}{" "}
|
||||
this Assistant should use to inform its
|
||||
responses. If none are specified, the
|
||||
Assistant will reference all available
|
||||
documents.
|
||||
</>
|
||||
</SubLabel>
|
||||
|
||||
<div className="mt-2 mb-4">
|
||||
<h4 className="text-xs font-normal mb-2">
|
||||
Selected Files and Folders
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFiles.map((file: FileResponse) => (
|
||||
<SourceChip
|
||||
key={file.id}
|
||||
onRemove={() => {
|
||||
removeSelectedFile(file);
|
||||
setFieldValue(
|
||||
"selectedFiles",
|
||||
values.selectedFiles.filter(
|
||||
(f: FileResponse) =>
|
||||
f.id !== file.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
title={file.name}
|
||||
icon={<FileIcon size={12} />}
|
||||
/>
|
||||
))}
|
||||
{selectedFolders.map(
|
||||
(folder: FolderResponse) => (
|
||||
<SourceChip
|
||||
key={folder.id}
|
||||
onRemove={() => {
|
||||
removeSelectedFolder(folder);
|
||||
setFieldValue(
|
||||
"selectedFolders",
|
||||
values.selectedFolders.filter(
|
||||
(f: FolderResponse) =>
|
||||
f.id !== folder.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
title={folder.name}
|
||||
icon={<FolderIcon size={12} />}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{values.knowledge_source === "team_knowledge" &&
|
||||
ccPairs.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<Label>Team Knowledge</Label>
|
||||
<div>
|
||||
<SubLabel>
|
||||
<>
|
||||
Select which{" "}
|
||||
{!user || user.role === "admin" ? (
|
||||
<Link
|
||||
href="/admin/documents/sets"
|
||||
className="font-semibold underline hover:underline text-text"
|
||||
target="_blank"
|
||||
>
|
||||
Team Document Sets
|
||||
</Link>
|
||||
) : (
|
||||
"Team Document Sets"
|
||||
)}{" "}
|
||||
this Assistant should use to inform its
|
||||
responses. If none are specified, the
|
||||
Assistant will reference all available
|
||||
documents.
|
||||
</>
|
||||
</SubLabel>
|
||||
</div>
|
||||
|
||||
{documentSets.length > 0 ? (
|
||||
<FieldArray
|
||||
name="document_set_ids"
|
||||
render={(arrayHelpers: ArrayHelpers) => (
|
||||
<div>
|
||||
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
|
||||
{documentSets.map((documentSet) => (
|
||||
<DocumentSetSelectable
|
||||
key={documentSet.id}
|
||||
documentSet={documentSet}
|
||||
isSelected={values.document_set_ids.includes(
|
||||
documentSet.id
|
||||
)}
|
||||
onSelect={() => {
|
||||
const index =
|
||||
values.document_set_ids.indexOf(
|
||||
documentSet.id
|
||||
);
|
||||
if (index !== -1) {
|
||||
arrayHelpers.remove(index);
|
||||
} else {
|
||||
arrayHelpers.push(
|
||||
documentSet.id
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{documentSets.length > 0 ? (
|
||||
<FieldArray
|
||||
name="document_set_ids"
|
||||
render={(arrayHelpers: ArrayHelpers) => (
|
||||
<div>
|
||||
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
|
||||
{documentSets.map((documentSet) => (
|
||||
<DocumentSetSelectable
|
||||
key={documentSet.id}
|
||||
documentSet={documentSet}
|
||||
isSelected={values.document_set_ids.includes(
|
||||
documentSet.id
|
||||
)}
|
||||
onSelect={() => {
|
||||
const index =
|
||||
values.document_set_ids.indexOf(
|
||||
documentSet.id
|
||||
);
|
||||
if (index !== -1) {
|
||||
arrayHelpers.remove(index);
|
||||
} else {
|
||||
arrayHelpers.push(
|
||||
documentSet.id
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href="/admin/documents/sets/new"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
+ Create Document Set
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href="/admin/documents/sets/new"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
+ Create Document Set
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import {
|
||||
FileResponse,
|
||||
FolderResponse,
|
||||
} from "@/app/chat/my-documents/DocumentsContext";
|
||||
|
||||
export interface AssistantFileChanges {
|
||||
filesToShare: number[];
|
||||
filesToUnshare: number[];
|
||||
foldersToShare: number[];
|
||||
foldersToUnshare: number[];
|
||||
}
|
||||
|
||||
export function calculateFileChanges(
|
||||
existingFileIds: number[],
|
||||
existingFolderIds: number[],
|
||||
selectedFiles: FileResponse[],
|
||||
selectedFolders: FolderResponse[]
|
||||
): AssistantFileChanges {
|
||||
const selectedFileIds = selectedFiles.map((file) => file.id);
|
||||
const selectedFolderIds = selectedFolders.map((folder) => folder.id);
|
||||
|
||||
return {
|
||||
filesToShare: selectedFileIds.filter((id) => !existingFileIds.includes(id)),
|
||||
filesToUnshare: existingFileIds.filter(
|
||||
(id) => !selectedFileIds.includes(id)
|
||||
),
|
||||
foldersToShare: selectedFolderIds.filter(
|
||||
(id) => !existingFolderIds.includes(id)
|
||||
),
|
||||
foldersToUnshare: existingFolderIds.filter(
|
||||
(id) => !selectedFolderIds.includes(id)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function shareFiles(
|
||||
assistantId: number,
|
||||
fileIds: number[]
|
||||
): Promise<void> {
|
||||
for (const fileId of fileIds) {
|
||||
await fetch(`/api/user/file/${fileId}/share`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ assistant_id: assistantId }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function unshareFiles(
|
||||
assistantId: number,
|
||||
fileIds: number[]
|
||||
): Promise<void> {
|
||||
for (const fileId of fileIds) {
|
||||
await fetch(`/api/user/file/${fileId}/unshare`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ assistant_id: assistantId }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareFolders(
|
||||
assistantId: number,
|
||||
folderIds: number[]
|
||||
): Promise<void> {
|
||||
for (const folderId of folderIds) {
|
||||
await fetch(`/api/user/folder/${folderId}/share`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ assistant_id: assistantId }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function unshareFolders(
|
||||
assistantId: number,
|
||||
folderIds: number[]
|
||||
): Promise<void> {
|
||||
for (const folderId of folderIds) {
|
||||
await fetch(`/api/user/folder/${folderId}/unshare`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ assistant_id: assistantId }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAssistantFiles(
|
||||
assistantId: number,
|
||||
changes: AssistantFileChanges
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
shareFiles(assistantId, changes.filesToShare),
|
||||
unshareFiles(assistantId, changes.filesToUnshare),
|
||||
shareFolders(assistantId, changes.foldersToShare),
|
||||
unshareFolders(assistantId, changes.foldersToUnshare),
|
||||
]);
|
||||
}
|
||||
@@ -45,8 +45,6 @@ export interface Persona {
|
||||
icon_color?: string;
|
||||
uploaded_image_id?: string;
|
||||
labels?: PersonaLabel[];
|
||||
user_file_ids?: number[];
|
||||
user_folder_ids?: number[];
|
||||
}
|
||||
|
||||
export interface PersonaLabel {
|
||||
|
||||
@@ -29,8 +29,6 @@ interface PersonaUpsertRequest {
|
||||
is_default_persona: boolean;
|
||||
display_priority: number | null;
|
||||
label_ids: number[] | null;
|
||||
user_file_ids: number[] | null;
|
||||
user_folder_ids: number[] | null;
|
||||
}
|
||||
|
||||
export interface PersonaUpsertParameters {
|
||||
@@ -58,8 +56,6 @@ export interface PersonaUpsertParameters {
|
||||
uploaded_image: File | null;
|
||||
is_default_persona: boolean;
|
||||
label_ids: number[] | null;
|
||||
user_file_ids: number[];
|
||||
user_folder_ids: number[];
|
||||
}
|
||||
|
||||
export const createPersonaLabel = (name: string) => {
|
||||
@@ -118,10 +114,7 @@ function buildPersonaUpsertRequest(
|
||||
icon_shape,
|
||||
remove_image,
|
||||
search_start_date,
|
||||
user_file_ids,
|
||||
user_folder_ids,
|
||||
} = creationRequest;
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
@@ -152,8 +145,6 @@ function buildPersonaUpsertRequest(
|
||||
starter_messages: creationRequest.starter_messages ?? null,
|
||||
display_priority: null,
|
||||
label_ids: creationRequest.label_ids ?? null,
|
||||
user_file_ids: user_file_ids ?? null,
|
||||
user_folder_ids: user_folder_ids ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -199,17 +199,17 @@ export function SlackChannelConfigFormFields({
|
||||
<Badge variant="agent" className="bg-blue-100 text-blue-800">
|
||||
Default Configuration
|
||||
</Badge>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
This default configuration will apply across all Slack channels
|
||||
the bot is added to in the Slack workspace, as well as direct
|
||||
messages (DMs), unless disabled.
|
||||
</p>
|
||||
<div className="mt-4 p-4 bg-gray-100 rounded-md border border-gray-300">
|
||||
<div className="mt-4 p-4 bg-neutral-100 rounded-md border border-neutral-300">
|
||||
<CheckFormField
|
||||
name="disabled"
|
||||
label="Disable Default Configuration"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-600 italic">
|
||||
<p className="mt-2 text-sm text-neutral-600 italic">
|
||||
Warning: Disabling the default configuration means the bot
|
||||
won't respond in Slack channels or DMs unless explicitly
|
||||
configured for them.
|
||||
@@ -238,20 +238,28 @@ export function SlackChannelConfigFormFields({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Field name="channel_name">
|
||||
{({ field, form }: { field: any; form: any }) => (
|
||||
<SearchMultiSelectDropdown
|
||||
options={channelOptions || []}
|
||||
onSelect={(selected) => {
|
||||
form.setFieldValue("channel_name", selected.name);
|
||||
}}
|
||||
initialSearchTerm={field.value}
|
||||
onSearchTermChange={(term) => {
|
||||
form.setFieldValue("channel_name", term);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<>
|
||||
<Field name="channel_name">
|
||||
{({ field, form }: { field: any; form: any }) => (
|
||||
<SearchMultiSelectDropdown
|
||||
options={channelOptions || []}
|
||||
onSelect={(selected) => {
|
||||
form.setFieldValue("channel_name", selected.name);
|
||||
}}
|
||||
initialSearchTerm={field.value}
|
||||
onSearchTermChange={(term) => {
|
||||
form.setFieldValue("channel_name", term);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<p className="mt-2 text-sm dark:text-neutral-400 text-neutral-600">
|
||||
Note: This list shows public and private channels where the
|
||||
bot is a member (up to 500 channels). If you don't see a
|
||||
channel, make sure the bot is added to that channel in Slack
|
||||
first, or type the channel name manually.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -41,12 +41,6 @@ export interface WellKnownLLMProviderDescriptor {
|
||||
groups: number[];
|
||||
}
|
||||
|
||||
export interface LLMModelDescriptor {
|
||||
modelName: string;
|
||||
provider: string;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
export interface LLMProvider {
|
||||
name: string;
|
||||
provider: string;
|
||||
@@ -60,7 +54,6 @@ export interface LLMProvider {
|
||||
groups: number[];
|
||||
display_model_names: string[] | null;
|
||||
deployment_name: string | null;
|
||||
model_token_limits: { [key: string]: number } | null;
|
||||
}
|
||||
|
||||
export interface FullLLMProvider extends LLMProvider {
|
||||
@@ -80,7 +73,6 @@ export interface LLMProviderDescriptor {
|
||||
is_public: boolean;
|
||||
groups: number[];
|
||||
display_model_names: string[] | null;
|
||||
model_token_limits: { [key: string]: number } | null;
|
||||
}
|
||||
|
||||
export const getProviderIcon = (providerName: string, modelName?: string) => {
|
||||
|
||||
@@ -4,6 +4,12 @@ export enum ApplicationStatus {
|
||||
ACTIVE = "active",
|
||||
}
|
||||
|
||||
export enum QueryHistoryType {
|
||||
DISABLED = "disabled",
|
||||
ANONYMIZED = "anonymized",
|
||||
NORMAL = "normal",
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
anonymous_user_enabled: boolean;
|
||||
maximum_chat_retention_days: number | null;
|
||||
@@ -14,6 +20,7 @@ export interface Settings {
|
||||
application_status: ApplicationStatus;
|
||||
auto_scroll: boolean;
|
||||
temperature_override_enabled: boolean;
|
||||
query_history_type: QueryHistoryType;
|
||||
}
|
||||
|
||||
export enum NotificationType {
|
||||
|
||||
@@ -23,15 +23,16 @@ import AssistantModal from "./mine/AssistantModal";
|
||||
import { useSidebarShortcut } from "@/lib/browserUtilities";
|
||||
|
||||
interface SidebarWrapperProps<T extends object> {
|
||||
initiallyToggled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function SidebarWrapper<T extends object>({
|
||||
initiallyToggled,
|
||||
size = "sm",
|
||||
children,
|
||||
}: SidebarWrapperProps<T>) {
|
||||
const { sidebarInitiallyVisible: initiallyToggled } = useChatContext();
|
||||
const [sidebarVisible, setSidebarVisible] = useState(initiallyToggled);
|
||||
const [showDocSidebar, setShowDocSidebar] = useState(false); // State to track if sidebar is open
|
||||
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
|
||||
@@ -134,7 +135,13 @@ export default function SidebarWrapper<T extends object>({
|
||||
${sidebarVisible ? "w-[250px]" : "w-[0px]"}`}
|
||||
/>
|
||||
|
||||
<div className={`mt-4 w-full mx-auto`}>{children}</div>
|
||||
<div
|
||||
className={`mt-4 w-full ${
|
||||
size == "lg" ? "max-w-4xl" : "max-w-3xl"
|
||||
} mx-auto`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FixedLogo backgroundToggled={sidebarVisible || showDocSidebar} />
|
||||
|
||||
16
web/src/app/auth/error/layout.tsx
Normal file
16
web/src/app/auth/error/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function AuthErrorLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Log error to console for debugging
|
||||
console.error(
|
||||
"Authentication error page was accessed - this should not happen in normal flow"
|
||||
);
|
||||
|
||||
// In a production environment, you might want to send this to your error tracking service
|
||||
// For example, if using a service like Sentry:
|
||||
// captureException(new Error("Authentication error page was accessed unexpectedly"));
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import AuthFlowContainer from "@/components/auth/AuthFlowContainer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { FiLogIn } from "react-icons/fi";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
@@ -15,19 +16,21 @@ const Page = () => {
|
||||
<p className="text-text-700 text-center">
|
||||
We encountered an issue while attempting to log you in.
|
||||
</p>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-red-800 font-semibold mb-2">Possible Issues:</h3>
|
||||
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-red-800 dark:text-red-400 font-semibold mb-2">
|
||||
Possible Issues:
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center text-red-700">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-2"></div>
|
||||
<li className="flex items-center text-red-700 dark:text-red-400">
|
||||
<div className="w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></div>
|
||||
Incorrect or expired login credentials
|
||||
</li>
|
||||
<li className="flex items-center text-red-700">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-2"></div>
|
||||
<li className="flex items-center text-red-700 dark:text-red-400">
|
||||
<div className="w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></div>
|
||||
Temporary authentication system disruption
|
||||
</li>
|
||||
<li className="flex items-center text-red-700">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-2"></div>
|
||||
<li className="flex items-center text-red-700 dark:text-red-400">
|
||||
<div className="w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></div>
|
||||
Account access restrictions or permissions
|
||||
</li>
|
||||
</ul>
|
||||
@@ -41,6 +44,12 @@ const Page = () => {
|
||||
<p className="text-sm text-text-500 text-center">
|
||||
We recommend trying again. If you continue to experience problems,
|
||||
please reach out to your system administrator for assistance.
|
||||
{NEXT_PUBLIC_CLOUD_ENABLED && (
|
||||
<span className="block mt-1 text-blue-600">
|
||||
A member of our team has been automatically notified about this
|
||||
issue.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</AuthFlowContainer>
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from "react";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams";
|
||||
import { useDocumentSelection } from "./useDocumentSelection";
|
||||
import { LlmDescriptor, useFilters, useLlmManager } from "@/lib/hooks";
|
||||
import { ChatState, FeedbackType, RegenerationState } from "./types";
|
||||
import { DocumentResults } from "./documentSidebar/DocumentResults";
|
||||
@@ -99,13 +100,14 @@ import { ChatInputBar } from "./input/ChatInputBar";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { ChatPopup } from "./ChatPopup";
|
||||
|
||||
import FunctionalHeader from "@/components/chat/Header";
|
||||
import { useSidebarVisibility } from "@/components/chat/hooks";
|
||||
import {
|
||||
PRO_SEARCH_TOGGLED_COOKIE_NAME,
|
||||
SIDEBAR_TOGGLED_COOKIE_NAME,
|
||||
} from "@/components/resizable/constants";
|
||||
import FixedLogo from "@/components/logo/FixedLogo";
|
||||
import FixedLogo from "../../components/logo/FixedLogo";
|
||||
|
||||
import { MinimalMarkdown } from "@/components/chat/MinimalMarkdown";
|
||||
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
|
||||
@@ -130,22 +132,12 @@ import {
|
||||
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
import { UserSettingsModal } from "./modal/UserSettingsModal";
|
||||
import { AlignStartVertical } from "lucide-react";
|
||||
import { AgenticMessage } from "./message/AgenticMessage";
|
||||
import AssistantModal from "../assistants/mine/AssistantModal";
|
||||
import { useSidebarShortcut } from "@/lib/browserUtilities";
|
||||
import { FilePickerModal } from "./my-documents/components/FilePicker";
|
||||
|
||||
import { SourceMetadata } from "@/lib/search/interfaces";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import {
|
||||
FileUploadResponse,
|
||||
FileResponse,
|
||||
useDocumentsContext,
|
||||
} from "./my-documents/DocumentsContext";
|
||||
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
|
||||
import { MessageChannel } from "node:worker_threads";
|
||||
import { ChatSearchModal } from "./chat_search/ChatSearchModal";
|
||||
import { ErrorBanner } from "./message/Resubmit";
|
||||
|
||||
const TEMP_USER_MESSAGE_ID = -1;
|
||||
const TEMP_ASSISTANT_MESSAGE_ID = -2;
|
||||
@@ -177,22 +169,10 @@ export function ChatPage({
|
||||
proSearchToggled,
|
||||
} = useChatContext();
|
||||
|
||||
const {
|
||||
selectedFiles,
|
||||
selectedFolders,
|
||||
addSelectedFile,
|
||||
addSelectedFolder,
|
||||
removeSelectedFolder,
|
||||
clearSelectedItems,
|
||||
folders: userFolders,
|
||||
uploadFile,
|
||||
} = useDocumentsContext();
|
||||
|
||||
const defaultAssistantIdRaw = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID);
|
||||
const defaultAssistantId = defaultAssistantIdRaw
|
||||
? parseInt(defaultAssistantIdRaw)
|
||||
: undefined;
|
||||
const [forceUserFileSearch, setForceUserFileSearch] = useState(true);
|
||||
|
||||
function useScreenSize() {
|
||||
const [screenSize, setScreenSize] = useState({
|
||||
@@ -222,8 +202,6 @@ export function ChatPage({
|
||||
const settings = useContext(SettingsContext);
|
||||
const enterpriseSettings = settings?.enterpriseSettings;
|
||||
|
||||
const [viewingFilePicker, setViewingFilePicker] = useState(false);
|
||||
const [toggleDocSelection, setToggleDocSelection] = useState(false);
|
||||
const [documentSidebarVisible, setDocumentSidebarVisible] = useState(false);
|
||||
const [proSearchEnabled, setProSearchEnabled] = useState(proSearchToggled);
|
||||
const [streamingAllowed, setStreamingAllowed] = useState(false);
|
||||
@@ -315,10 +293,10 @@ export function ChatPage({
|
||||
(assistant) => assistant.id === existingChatSessionAssistantId
|
||||
)
|
||||
: defaultAssistantId !== undefined
|
||||
? availableAssistants.find(
|
||||
(assistant) => assistant.id === defaultAssistantId
|
||||
)
|
||||
: undefined
|
||||
? availableAssistants.find(
|
||||
(assistant) => assistant.id === defaultAssistantId
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
// Gather default temperature settings
|
||||
const search_param_temperature = searchParams.get(
|
||||
@@ -328,12 +306,12 @@ export function ChatPage({
|
||||
const defaultTemperature = search_param_temperature
|
||||
? parseFloat(search_param_temperature)
|
||||
: selectedAssistant?.tools.some(
|
||||
(tool) =>
|
||||
tool.in_code_tool_id === SEARCH_TOOL_ID ||
|
||||
tool.in_code_tool_id === INTERNET_SEARCH_TOOL_ID
|
||||
)
|
||||
? 0
|
||||
: 0.7;
|
||||
(tool) =>
|
||||
tool.in_code_tool_id === SEARCH_TOOL_ID ||
|
||||
tool.in_code_tool_id === INTERNET_SEARCH_TOOL_ID
|
||||
)
|
||||
? 0
|
||||
: 0.7;
|
||||
|
||||
const setSelectedAssistantFromId = (assistantId: number) => {
|
||||
// NOTE: also intentionally look through available assistants here, so that
|
||||
@@ -378,14 +356,9 @@ export function ChatPage({
|
||||
|
||||
const noAssistants = liveAssistant == null || liveAssistant == undefined;
|
||||
|
||||
const availableSources: ValidSources[] = useMemo(() => {
|
||||
return ccPairs.map((ccPair) => ccPair.source);
|
||||
}, [ccPairs]);
|
||||
|
||||
const sources: SourceMetadata[] = useMemo(() => {
|
||||
const uniqueSources = Array.from(new Set(availableSources));
|
||||
return uniqueSources.map((source) => getSourceMetadata(source));
|
||||
}, [availableSources]);
|
||||
const availableSources = ccPairs.map((ccPair) => ccPair.source);
|
||||
const uniqueSources = Array.from(new Set(availableSources));
|
||||
const sources = uniqueSources.map((source) => getSourceMetadata(source));
|
||||
|
||||
const stopGenerating = () => {
|
||||
const currentSession = currentSessionId();
|
||||
@@ -582,18 +555,6 @@ export function ChatPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [existingChatSessionId, searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID)]);
|
||||
|
||||
useEffect(() => {
|
||||
const userFolderId = searchParams.get(SEARCH_PARAM_NAMES.USER_FOLDER_ID);
|
||||
if (userFolderId) {
|
||||
const userFolder = userFolders.find(
|
||||
(folder) => folder.id === parseInt(userFolderId)
|
||||
);
|
||||
if (userFolder) {
|
||||
addSelectedFolder(userFolder);
|
||||
}
|
||||
}
|
||||
}, [userFolders, searchParams.get(SEARCH_PARAM_NAMES.USER_FOLDER_ID)]);
|
||||
|
||||
const [message, setMessage] = useState(
|
||||
searchParams.get(SEARCH_PARAM_NAMES.USER_PROMPT) || ""
|
||||
);
|
||||
@@ -879,6 +840,13 @@ export function ChatPage({
|
||||
);
|
||||
}
|
||||
}, [submittedMessage, currentSessionChatState]);
|
||||
|
||||
const [
|
||||
selectedDocuments,
|
||||
toggleDocumentSelection,
|
||||
clearSelectedDocuments,
|
||||
selectedDocumentTokens,
|
||||
] = useDocumentSelection();
|
||||
// just choose a conservative default, this will be updated in the
|
||||
// background on initial load / on persona change
|
||||
const [maxTokens, setMaxTokens] = useState<number>(4096);
|
||||
@@ -1195,6 +1163,7 @@ export function ChatPage({
|
||||
navigatingAway.current = false;
|
||||
let frozenSessionId = currentSessionId();
|
||||
updateCanContinue(false, frozenSessionId);
|
||||
setUncaughtError(null);
|
||||
|
||||
// Mark that we've sent a message for this session in the current page load
|
||||
markSessionMessageSent(frozenSessionId);
|
||||
@@ -1345,6 +1314,7 @@ export function ChatPage({
|
||||
let isStreamingQuestions = true;
|
||||
let includeAgentic = false;
|
||||
let secondLevelMessageId: number | null = null;
|
||||
let isAgentic: boolean = false;
|
||||
|
||||
let initialFetchDetails: null | {
|
||||
user_message_id: number;
|
||||
@@ -1376,9 +1346,7 @@ export function ChatPage({
|
||||
filterManager.selectedSources,
|
||||
filterManager.selectedDocumentSets,
|
||||
filterManager.timeRange,
|
||||
filterManager.selectedTags,
|
||||
selectedFiles.map((file) => file.id),
|
||||
selectedFolders.map((folder) => folder.id)
|
||||
filterManager.selectedTags
|
||||
),
|
||||
selectedDocumentIds: selectedDocuments
|
||||
.filter(
|
||||
@@ -1388,8 +1356,6 @@ export function ChatPage({
|
||||
.map((document) => document.db_doc_id as number),
|
||||
queryOverride,
|
||||
forceSearch,
|
||||
userFolderIds: selectedFolders.map((folder) => folder.id),
|
||||
userFileIds: selectedFiles.map((file) => file.id),
|
||||
regenerate: regenerationRequest !== undefined,
|
||||
modelProvider:
|
||||
modelOverride?.name || llmManager.currentLlm.name || undefined,
|
||||
@@ -1406,7 +1372,6 @@ export function ChatPage({
|
||||
settings?.settings.pro_search_enabled &&
|
||||
proSearchEnabled &&
|
||||
retrievalEnabled,
|
||||
forceUserFileSearch: forceUserFileSearch,
|
||||
});
|
||||
|
||||
const delay = (ms: number) => {
|
||||
@@ -1512,6 +1477,9 @@ export function ChatPage({
|
||||
second_level_generating = true;
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(packet, "is_agentic")) {
|
||||
isAgentic = (packet as any).is_agentic;
|
||||
}
|
||||
|
||||
if (Object.hasOwn(packet, "refined_answer_improvement")) {
|
||||
isImprovement = (packet as RefinedAnswerImprovement)
|
||||
@@ -1545,6 +1513,7 @@ export function ChatPage({
|
||||
);
|
||||
} else if (Object.hasOwn(packet, "sub_question")) {
|
||||
updateChatState("toolBuilding", frozenSessionId);
|
||||
isAgentic = true;
|
||||
is_generating = true;
|
||||
sub_questions = constructSubQuestions(
|
||||
sub_questions,
|
||||
@@ -1745,6 +1714,7 @@ export function ChatPage({
|
||||
sub_questions: sub_questions,
|
||||
second_level_generating: second_level_generating,
|
||||
agentic_docs: agenticDocs,
|
||||
is_agentic: isAgentic,
|
||||
},
|
||||
...(includeAgentic
|
||||
? [
|
||||
@@ -1905,61 +1875,17 @@ export function ChatPage({
|
||||
};
|
||||
updateChatState("uploading", currentSessionId());
|
||||
|
||||
// const files = await uploadFilesForChat(acceptedFiles).then(
|
||||
// ([files, error]) => {
|
||||
// if (error) {
|
||||
// setCurrentMessageFiles((prev) => removeTempFiles(prev));
|
||||
// setPopup({
|
||||
// type: "error",
|
||||
// message: error,
|
||||
// });
|
||||
// } else {
|
||||
// setCurrentMessageFiles((prev) => [
|
||||
// ...removeTempFiles(prev),
|
||||
// ...files,
|
||||
// ]);
|
||||
// }
|
||||
// return files;
|
||||
// }
|
||||
// );
|
||||
|
||||
for (let i = 0; i < acceptedFiles.length; i++) {
|
||||
const file = acceptedFiles[i];
|
||||
const formData = new FormData();
|
||||
formData.append("files", file);
|
||||
const response: FileUploadResponse = await uploadFile(formData, null);
|
||||
|
||||
if (response.file_paths && response.file_paths.length > 0) {
|
||||
const uploadedFile: FileResponse = {
|
||||
id: Date.now(),
|
||||
name: file.name,
|
||||
document_id: response.file_paths[0],
|
||||
folder_id: null,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: new Date().toISOString(),
|
||||
token_count: 0,
|
||||
};
|
||||
addSelectedFile(uploadedFile);
|
||||
await uploadFilesForChat(acceptedFiles).then(([files, error]) => {
|
||||
if (error) {
|
||||
setCurrentMessageFiles((prev) => removeTempFiles(prev));
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: error,
|
||||
});
|
||||
} else {
|
||||
setCurrentMessageFiles((prev) => [...removeTempFiles(prev), ...files]);
|
||||
}
|
||||
}
|
||||
|
||||
// const fileToAdd: FileResponse[] = files.map((file: FileDescriptor) => {
|
||||
// return {
|
||||
// document_id: file.id,
|
||||
// type: file.type.startsWith("image/")
|
||||
// ? ChatFileType.IMAGE
|
||||
// : ChatFileType.DOCUMENT,
|
||||
// name: file.name || "Name not available",
|
||||
// size: 10,
|
||||
// folder_id: -1,
|
||||
// id: 10,
|
||||
// };
|
||||
// });
|
||||
// setSelectedFiles((prevFiles: FileResponse[]) => [
|
||||
// ...prevFiles,
|
||||
// ...fileToAdd,
|
||||
// ]);
|
||||
});
|
||||
updateChatState("input", currentSessionId());
|
||||
};
|
||||
|
||||
@@ -2055,11 +1981,6 @@ export function ChatPage({
|
||||
const [settingsToggled, setSettingsToggled] = useState(false);
|
||||
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false);
|
||||
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<OnyxDocument[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedDocumentTokens, setSelectedDocumentTokens] = useState(0);
|
||||
|
||||
const currentPersona = alternativeAssistant || liveAssistant;
|
||||
|
||||
const HORIZON_DISTANCE = 800;
|
||||
@@ -2142,6 +2063,26 @@ export function ChatPage({
|
||||
const [sharedChatSession, setSharedChatSession] =
|
||||
useState<ChatSession | null>();
|
||||
|
||||
const handleResubmitLastMessage = () => {
|
||||
// Grab the last user-type message
|
||||
const lastUserMsg = messageHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => m.type === "user");
|
||||
if (!lastUserMsg) {
|
||||
setPopup({
|
||||
message: "No previously-submitted user message found.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// We call onSubmit, passing a `messageOverride`
|
||||
onSubmit({
|
||||
messageIdToResend: lastUserMsg.messageId,
|
||||
messageOverride: lastUserMsg.message,
|
||||
});
|
||||
};
|
||||
|
||||
const showShareModal = (chatSession: ChatSession) => {
|
||||
setSharedChatSession(chatSession);
|
||||
};
|
||||
@@ -2184,28 +2125,6 @@ export function ChatPage({
|
||||
</>
|
||||
);
|
||||
|
||||
const clearSelectedDocuments = () => {
|
||||
setSelectedDocuments([]);
|
||||
setSelectedDocumentTokens(0);
|
||||
clearSelectedItems();
|
||||
};
|
||||
|
||||
const toggleDocumentSelection = (document: OnyxDocument) => {
|
||||
setSelectedDocuments((prev) =>
|
||||
prev.some((d) => d.document_id === document.document_id)
|
||||
? prev.filter((d) => d.document_id !== document.document_id)
|
||||
: [...prev, document]
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: File[]) => {
|
||||
// Implement file upload logic here
|
||||
// After successful upload, you might want to add the file to selected files
|
||||
// For example:
|
||||
// const uploadedFile = await uploadFile(files[0]);
|
||||
// addSelectedFile(uploadedFile);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HealthCheckBanner />
|
||||
@@ -2278,40 +2197,6 @@ export function ChatPage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{toggleDocSelection && (
|
||||
<FilePickerModal
|
||||
buttonContent="Set as Context"
|
||||
title="User Documents"
|
||||
isOpen={true}
|
||||
onClose={() => setToggleDocSelection(false)}
|
||||
onSave={() => {
|
||||
setToggleDocSelection(false);
|
||||
}}
|
||||
selectedFiles={selectedFiles}
|
||||
selectedFolders={selectedFolders}
|
||||
addSelectedFile={addSelectedFile}
|
||||
addSelectedFolder={addSelectedFolder}
|
||||
removeSelectedFile={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toggleDocSelection && (
|
||||
<FilePickerModal
|
||||
buttonContent="Set as Context"
|
||||
title="User Documents"
|
||||
isOpen={true}
|
||||
onClose={() => setToggleDocSelection(false)}
|
||||
onSave={() => {
|
||||
setToggleDocSelection(false);
|
||||
}}
|
||||
selectedFiles={selectedFiles}
|
||||
selectedFolders={selectedFolders}
|
||||
addSelectedFile={addSelectedFile}
|
||||
addSelectedFolder={addSelectedFolder}
|
||||
removeSelectedFile={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatSearchModal
|
||||
open={isChatSearchModalOpen}
|
||||
onCloseModal={() => setIsChatSearchModalOpen(false)}
|
||||
@@ -2780,9 +2665,9 @@ export function ChatPage({
|
||||
: null
|
||||
}
|
||||
>
|
||||
{message.sub_questions &&
|
||||
message.sub_questions.length > 0 ? (
|
||||
{message.is_agentic ? (
|
||||
<AgenticMessage
|
||||
resubmit={handleResubmitLastMessage}
|
||||
error={uncaughtError}
|
||||
isStreamingQuestions={
|
||||
message.isStreamingQuestions ?? false
|
||||
@@ -3130,21 +3015,18 @@ export function ChatPage({
|
||||
currentPersona={liveAssistant}
|
||||
messageId={message.messageId}
|
||||
content={
|
||||
<p className="text-red-700 text-sm my-auto">
|
||||
{message.message}
|
||||
{message.stackTrace && (
|
||||
<span
|
||||
onClick={() =>
|
||||
setStackTraceModalContent(
|
||||
message.stackTrace!
|
||||
)
|
||||
}
|
||||
className="ml-2 cursor-pointer underline"
|
||||
>
|
||||
Show stack trace.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<ErrorBanner
|
||||
resubmit={handleResubmitLastMessage}
|
||||
error={message.message}
|
||||
showStackTrace={
|
||||
message.stackTrace
|
||||
? () =>
|
||||
setStackTraceModalContent(
|
||||
message.stackTrace!
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -3250,26 +3132,23 @@ export function ChatPage({
|
||||
clearSelectedDocuments();
|
||||
}}
|
||||
retrievalEnabled={retrievalEnabled}
|
||||
toggleDocSelection={() =>
|
||||
setToggleDocSelection(true)
|
||||
}
|
||||
showConfigureAPIKey={() =>
|
||||
setShowApiKeyModal(true)
|
||||
}
|
||||
chatState={currentSessionChatState}
|
||||
stopGenerating={stopGenerating}
|
||||
selectedDocuments={selectedDocuments}
|
||||
// assistant stuff
|
||||
selectedAssistant={liveAssistant}
|
||||
setAlternativeAssistant={setAlternativeAssistant}
|
||||
alternativeAssistant={alternativeAssistant}
|
||||
// end assistant stuff
|
||||
message={message}
|
||||
setMessage={setMessage}
|
||||
stopGenerating={stopGenerating}
|
||||
onSubmit={onSubmit}
|
||||
chatState={currentSessionChatState}
|
||||
alternativeAssistant={alternativeAssistant}
|
||||
selectedAssistant={
|
||||
selectedAssistant || finalAssistants[0]
|
||||
}
|
||||
setAlternativeAssistant={setAlternativeAssistant}
|
||||
files={currentMessageFiles}
|
||||
setFiles={setCurrentMessageFiles}
|
||||
handleFileUpload={handleFileUpload}
|
||||
handleFileUpload={handleImageUpload}
|
||||
textAreaRef={textAreaRef}
|
||||
/>
|
||||
{enterpriseSettings &&
|
||||
@@ -3341,24 +3220,6 @@ export function ChatPage({
|
||||
</div>
|
||||
{/* Right Sidebar - DocumentSidebar */}
|
||||
</div>
|
||||
|
||||
{/* Add the fixed toggle button */}
|
||||
<div className="fixed right-4 top-1/2 transform -translate-y-1/2 z-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
setPopup({
|
||||
message: "This feature is not available yet.",
|
||||
type: "error",
|
||||
});
|
||||
setForceUserFileSearch(!forceUserFileSearch);
|
||||
}}
|
||||
className={`p-2 rounded-full ${
|
||||
forceUserFileSearch ? "bg-blue-500" : "bg-gray-300"
|
||||
} transition-colors duration-200`}
|
||||
>
|
||||
{forceUserFileSearch ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ export function ChatSearchGroup({
|
||||
}: ChatSearchGroupProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="sticky -top-1 mt-1 z-10 bg-[#fff]/90 dark:bg-gray-800/90 py-2 px-4 px-4">
|
||||
<div className="text-xs font-medium leading-4 text-gray-600 dark:text-gray-400">
|
||||
<div className="sticky -top-1 mt-1 z-10 bg-[#fff]/90 dark:bg-neutral-800/90 py-2 px-4 px-4">
|
||||
<div className="text-xs font-medium leading-4 text-neutral-600 dark:text-neutral-400">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { ChatSessionSummary } from "./interfaces";
|
||||
import { truncateString } from "@/lib/utils";
|
||||
|
||||
interface ChatSearchItemProps {
|
||||
chat: ChatSessionSummary;
|
||||
@@ -11,12 +12,12 @@ export function ChatSearchItem({ chat, onSelect }: ChatSearchItemProps) {
|
||||
return (
|
||||
<li>
|
||||
<div className="cursor-pointer" onClick={() => onSelect(chat.id)}>
|
||||
<div className="group relative flex flex-col rounded-lg px-4 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800">
|
||||
<div className="flex items-center">
|
||||
<div className="group relative flex flex-col rounded-lg px-4 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<div className="flex max-w-full mx-2 items-center">
|
||||
<MessageSquare className="h-5 w-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="relative grow overflow-hidden whitespace-nowrap pl-4">
|
||||
<div className="text-sm dark:text-neutral-200">
|
||||
{chat.name || "Untitled Chat"}
|
||||
<div className="relative max-w-full grow overflow-hidden whitespace-nowrap pl-4">
|
||||
<div className="text-sm max-w-full dark:text-neutral-200">
|
||||
{truncateString(chat.name || "Untitled Chat", 90)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 dark:text-neutral-400">
|
||||
|
||||
@@ -168,7 +168,7 @@ const FolderItem = ({
|
||||
};
|
||||
|
||||
const folders = folder.chat_sessions.sort((a, b) => {
|
||||
return a.time_created.localeCompare(b.time_created);
|
||||
return a.time_updated.localeCompare(b.time_updated);
|
||||
});
|
||||
|
||||
// Determine whether to show the trash can icon
|
||||
|
||||
@@ -8,8 +8,7 @@ export async function createFolder(folderName: string): Promise<number> {
|
||||
body: JSON.stringify({ folder_name: folderName }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to create folder");
|
||||
throw new Error("Failed to create folder");
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { InputPrompt } from "@/app/chat/interfaces";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrashIcon, PlusIcon } from "@/components/icons/icons";
|
||||
import { MoreVertical, CheckIcon, XIcon } from "lucide-react";
|
||||
import { PlusIcon } from "@/components/icons/icons";
|
||||
import { MoreVertical, XIcon } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import Title from "@/components/ui/title";
|
||||
import Text from "@/components/ui/text";
|
||||
@@ -153,114 +153,6 @@ export default function InputPrompts() {
|
||||
}
|
||||
};
|
||||
|
||||
const PromptCard = ({ prompt }: { prompt: InputPrompt }) => {
|
||||
const isEditing = editingPromptId === prompt.id;
|
||||
const [localPrompt, setLocalPrompt] = useState(prompt.prompt);
|
||||
const [localContent, setLocalContent] = useState(prompt.content);
|
||||
|
||||
// Sync local edits with any prompt changes from outside
|
||||
useEffect(() => {
|
||||
setLocalPrompt(prompt.prompt);
|
||||
setLocalContent(prompt.content);
|
||||
}, [prompt, isEditing]);
|
||||
|
||||
const handleLocalEdit = (field: "prompt" | "content", value: string) => {
|
||||
if (field === "prompt") {
|
||||
setLocalPrompt(value);
|
||||
} else {
|
||||
setLocalContent(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLocal = () => {
|
||||
handleSave(prompt.id, localPrompt, localContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border dark:border-none dark:bg-[#333333] rounded-lg p-4 mb-4 relative">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingPromptId(null);
|
||||
fetchInputPrompts(); // Revert changes from server
|
||||
}}
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="flex-grow mr-4">
|
||||
<Textarea
|
||||
value={localPrompt}
|
||||
onChange={(e) => handleLocalEdit("prompt", e.target.value)}
|
||||
className="mb-2 resize-none"
|
||||
placeholder="Prompt"
|
||||
/>
|
||||
<Textarea
|
||||
value={localContent}
|
||||
onChange={(e) => handleLocalEdit("content", e.target.value)}
|
||||
className="resize-vertical min-h-[100px]"
|
||||
placeholder="Content"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleSaveLocal}>
|
||||
{prompt.id ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mb-2 flex gap-x-2 ">
|
||||
<p className="font-semibold">{prompt.prompt}</p>
|
||||
{isPromptPublic(prompt) && <SourceChip title="Built-in" />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{isPromptPublic(prompt) && (
|
||||
<TooltipContent>
|
||||
<p>This is a built-in prompt and cannot be edited</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="whitespace-pre-wrap">{prompt.content}</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="hover:bg-transparent" asChild>
|
||||
<Button
|
||||
className="!hover:bg-transparent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!isPromptPublic(prompt) && (
|
||||
<DropdownMenuItem onClick={() => handleEdit(prompt.id)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => handleDelete(prompt.id)}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="absolute top-4 left-4">
|
||||
@@ -272,13 +164,21 @@ export default function InputPrompts() {
|
||||
<Title>Prompt Shortcuts</Title>
|
||||
<Text>
|
||||
Manage and customize prompt shortcuts for your assistants. Use your
|
||||
prompt shortcuts by starting a new message “/” in chat.
|
||||
prompt shortcuts by starting a new message with "/" in
|
||||
chat.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputPrompts.map((prompt) => (
|
||||
<PromptCard key={prompt.id} prompt={prompt} />
|
||||
<PromptCard
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
onEdit={handleEdit}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
isEditing={editingPromptId === prompt.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isCreatingNew ? (
|
||||
@@ -315,3 +215,129 @@ export default function InputPrompts() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PromptCardProps {
|
||||
prompt: InputPrompt;
|
||||
onEdit: (id: number) => void;
|
||||
onSave: (id: number, prompt: string, content: string) => void;
|
||||
onDelete: (id: number) => void;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
const PromptCard: React.FC<PromptCardProps> = ({
|
||||
prompt,
|
||||
onEdit,
|
||||
onSave,
|
||||
onDelete,
|
||||
isEditing,
|
||||
}) => {
|
||||
const [localPrompt, setLocalPrompt] = useState(prompt.prompt);
|
||||
const [localContent, setLocalContent] = useState(prompt.content);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPrompt(prompt.prompt);
|
||||
setLocalContent(prompt.content);
|
||||
}, [prompt, isEditing]);
|
||||
|
||||
const handleLocalEdit = useCallback(
|
||||
(field: "prompt" | "content", value: string) => {
|
||||
if (field === "prompt") {
|
||||
setLocalPrompt(value);
|
||||
} else {
|
||||
setLocalContent(value);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSaveLocal = useCallback(() => {
|
||||
onSave(prompt.id, localPrompt, localContent);
|
||||
}, [prompt.id, localPrompt, localContent, onSave]);
|
||||
|
||||
const isPromptPublic = useCallback((p: InputPrompt): boolean => {
|
||||
return p.is_public;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="border dark:border-none dark:bg-[#333333] rounded-lg p-4 mb-4 relative">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onEdit(0);
|
||||
}}
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="flex-grow mr-4">
|
||||
<Textarea
|
||||
value={localPrompt}
|
||||
onChange={(e) => handleLocalEdit("prompt", e.target.value)}
|
||||
className="mb-2 resize-none"
|
||||
placeholder="Prompt"
|
||||
/>
|
||||
<Textarea
|
||||
value={localContent}
|
||||
onChange={(e) => handleLocalEdit("content", e.target.value)}
|
||||
className="resize-vertical min-h-[100px]"
|
||||
placeholder="Content"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleSaveLocal}>
|
||||
{prompt.id ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mb-2 flex gap-x-2 ">
|
||||
<p className="font-semibold">{prompt.prompt}</p>
|
||||
{isPromptPublic(prompt) && <SourceChip title="Built-in" />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{isPromptPublic(prompt) && (
|
||||
<TooltipContent>
|
||||
<p>This is a built-in prompt and cannot be edited</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="whitespace-pre-wrap">{prompt.content}</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="hover:bg-transparent" asChild>
|
||||
<Button
|
||||
className="!hover:bg-transparent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!isPromptPublic(prompt) && (
|
||||
<DropdownMenuItem onClick={() => onEdit(prompt.id)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onDelete(prompt.id)}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
147
web/src/app/chat/input-prompts/PromptCard.tsx
Normal file
147
web/src/app/chat/input-prompts/PromptCard.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { SourceChip } from "../input/ChatInputBar";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { InputPrompt } from "../interfaces";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { XIcon } from "@/components/icons/icons";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
|
||||
export const PromptCard = ({
|
||||
prompt,
|
||||
editingPromptId,
|
||||
setEditingPromptId,
|
||||
handleSave,
|
||||
handleDelete,
|
||||
isPromptPublic,
|
||||
handleEdit,
|
||||
fetchInputPrompts,
|
||||
}: {
|
||||
prompt: InputPrompt;
|
||||
editingPromptId: number | null;
|
||||
setEditingPromptId: (id: number | null) => void;
|
||||
handleSave: (id: number, prompt: string, content: string) => void;
|
||||
handleDelete: (id: number) => void;
|
||||
isPromptPublic: (prompt: InputPrompt) => boolean;
|
||||
handleEdit: (id: number) => void;
|
||||
fetchInputPrompts: () => void;
|
||||
}) => {
|
||||
const isEditing = editingPromptId === prompt.id;
|
||||
const [localPrompt, setLocalPrompt] = useState(prompt.prompt);
|
||||
const [localContent, setLocalContent] = useState(prompt.content);
|
||||
|
||||
// Sync local edits with any prompt changes from outside
|
||||
useEffect(() => {
|
||||
setLocalPrompt(prompt.prompt);
|
||||
setLocalContent(prompt.content);
|
||||
}, [prompt, isEditing]);
|
||||
|
||||
const handleLocalEdit = (field: "prompt" | "content", value: string) => {
|
||||
if (field === "prompt") {
|
||||
setLocalPrompt(value);
|
||||
} else {
|
||||
setLocalContent(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLocal = () => {
|
||||
handleSave(prompt.id, localPrompt, localContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border dark:border-none dark:bg-[#333333] rounded-lg p-4 mb-4 relative">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingPromptId(null);
|
||||
fetchInputPrompts(); // Revert changes from server
|
||||
}}
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="flex-grow mr-4">
|
||||
<Textarea
|
||||
value={localPrompt}
|
||||
onChange={(e) => handleLocalEdit("prompt", e.target.value)}
|
||||
className="mb-2 resize-none"
|
||||
placeholder="Prompt"
|
||||
/>
|
||||
<Textarea
|
||||
value={localContent}
|
||||
onChange={(e) => handleLocalEdit("content", e.target.value)}
|
||||
className="resize-vertical min-h-[100px]"
|
||||
placeholder="Content"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleSaveLocal}>
|
||||
{prompt.id ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mb-2 flex gap-x-2 ">
|
||||
<p className="font-semibold">{prompt.prompt}</p>
|
||||
{isPromptPublic(prompt) && <SourceChip title="Built-in" />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{isPromptPublic(prompt) && (
|
||||
<TooltipContent>
|
||||
<p>This is a built-in prompt and cannot be edited</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="whitespace-pre-wrap">{prompt.content}</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="hover:bg-transparent" asChild>
|
||||
<Button
|
||||
className="!hover:bg-transparent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!isPromptPublic(prompt) && (
|
||||
<DropdownMenuItem onClick={() => handleEdit(prompt.id)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => handleDelete(prompt.id)}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,7 @@ import { Hoverable } from "@/components/Hoverable";
|
||||
import { ChatState } from "../types";
|
||||
import UnconfiguredProviderText from "@/components/chat/UnconfiguredProviderText";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { CalendarIcon, TagIcon, XIcon, FolderIcon } from "lucide-react";
|
||||
import { CalendarIcon, TagIcon, XIcon } from "lucide-react";
|
||||
import { FilterPopup } from "@/components/search/filtering/FilterPopup";
|
||||
import { DocumentSet, Tag } from "@/lib/types";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
@@ -35,7 +35,6 @@ import { getFormattedDateRangeString } from "@/lib/dateUtils";
|
||||
import { truncateString } from "@/lib/utils";
|
||||
import { buildImgUrl } from "../files/images/utils";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { useDocumentsContext } from "../my-documents/DocumentsContext";
|
||||
import { AgenticToggle } from "./AgenticToggle";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { LoadingIndicator } from "react-select/dist/declarations/src/components/indicators";
|
||||
@@ -155,7 +154,6 @@ export const SourceChip = ({
|
||||
gap-x-1
|
||||
h-6
|
||||
${onClick ? "cursor-pointer" : ""}
|
||||
animate-fade-in-scale
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
@@ -174,7 +172,6 @@ export const SourceChip = ({
|
||||
);
|
||||
|
||||
interface ChatInputBarProps {
|
||||
toggleDocSelection: () => void;
|
||||
removeDocs: () => void;
|
||||
showConfigureAPIKey: () => void;
|
||||
selectedDocuments: OnyxDocument[];
|
||||
@@ -203,7 +200,6 @@ interface ChatInputBarProps {
|
||||
}
|
||||
|
||||
export function ChatInputBar({
|
||||
toggleDocSelection,
|
||||
retrievalEnabled,
|
||||
removeDocs,
|
||||
toggleDocumentSidebar,
|
||||
@@ -233,13 +229,6 @@ export function ChatInputBar({
|
||||
setProSearchEnabled,
|
||||
}: ChatInputBarProps) {
|
||||
const { user } = useUser();
|
||||
const {
|
||||
selectedFiles,
|
||||
selectedFolders,
|
||||
removeSelectedFile,
|
||||
removeSelectedFolder,
|
||||
} = useDocumentsContext();
|
||||
|
||||
const settings = useContext(SettingsContext);
|
||||
useEffect(() => {
|
||||
const textarea = textAreaRef.current;
|
||||
@@ -638,8 +627,6 @@ export function ChatInputBar({
|
||||
/>
|
||||
|
||||
{(selectedDocuments.length > 0 ||
|
||||
selectedFiles.length > 0 ||
|
||||
selectedFolders.length > 0 ||
|
||||
files.length > 0 ||
|
||||
filterManager.timeRange ||
|
||||
filterManager.selectedDocumentSets.length > 0 ||
|
||||
@@ -663,24 +650,6 @@ export function ChatInputBar({
|
||||
/>
|
||||
))}
|
||||
|
||||
{selectedFiles.map((file) => (
|
||||
<SourceChip
|
||||
key={file.id}
|
||||
icon={<FileIcon size={16} />}
|
||||
title={file.name}
|
||||
onRemove={() => removeSelectedFile(file)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selectedFolders.map((folder) => (
|
||||
<SourceChip
|
||||
key={folder.id}
|
||||
icon={<FolderIcon size={16} />}
|
||||
title={folder.name}
|
||||
onRemove={() => removeSelectedFolder(folder)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filterManager.timeRange && (
|
||||
<SourceChip
|
||||
truncateTitle={false}
|
||||
@@ -788,38 +757,26 @@ export function ChatInputBar({
|
||||
|
||||
<div className="flex pr-4 pb-2 justify-between bg-input-background items-center w-full ">
|
||||
<div className="space-x-1 flex px-4 ">
|
||||
{retrievalEnabled ? (
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
Icon={FiPlusCircle}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.onchange = (event: any) => {
|
||||
const files = Array.from(
|
||||
event?.target?.files || []
|
||||
) as File[];
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
tooltipContent={"Upload files"}
|
||||
/>
|
||||
) : (
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
Icon={FiPlusCircle}
|
||||
onClick={() => {
|
||||
toggleDocSelection();
|
||||
}}
|
||||
tooltipContent={"Upload files and attach user files"}
|
||||
/>
|
||||
)}
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
Icon={FiPlusCircle}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.onchange = (event: any) => {
|
||||
const files = Array.from(
|
||||
event?.target?.files || []
|
||||
) as File[];
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
tooltipContent={"Upload files"}
|
||||
/>
|
||||
|
||||
<LLMPopover
|
||||
llmProviders={llmProviders}
|
||||
|
||||
@@ -58,7 +58,6 @@ export default function LLMPopover({
|
||||
icon: React.FC<{ size?: number; className?: string }>;
|
||||
}[];
|
||||
} = {};
|
||||
|
||||
const uniqueModelNames = new Set<string>();
|
||||
|
||||
llmProviders.forEach((llmProvider) => {
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface ChatSession {
|
||||
name: string;
|
||||
persona_id: number;
|
||||
time_created: string;
|
||||
time_updated: string;
|
||||
shared_status: ChatSessionSharedStatus;
|
||||
folder_id: number | null;
|
||||
current_alternate_model: string;
|
||||
@@ -103,6 +104,7 @@ export interface Message {
|
||||
overridden_model?: string;
|
||||
stopReason?: StreamStopReason | null;
|
||||
sub_questions?: SubQuestionDetail[] | null;
|
||||
is_agentic?: boolean | null;
|
||||
|
||||
// Streaming only
|
||||
second_level_generating?: boolean;
|
||||
@@ -122,6 +124,7 @@ export interface BackendChatSession {
|
||||
persona_icon_shape: number | null;
|
||||
messages: BackendMessage[];
|
||||
time_created: string;
|
||||
time_updated: string;
|
||||
shared_status: ChatSessionSharedStatus;
|
||||
current_temperature_override: number | null;
|
||||
current_alternate_model?: string;
|
||||
@@ -148,6 +151,7 @@ export interface BackendMessage {
|
||||
comments: any;
|
||||
parentMessageId: number | null;
|
||||
refined_answer_improvement: boolean | null;
|
||||
is_agentic: boolean | null;
|
||||
}
|
||||
|
||||
export interface MessageResponseIDInfo {
|
||||
|
||||
@@ -48,10 +48,10 @@ export function getChatRetentionInfo(
|
||||
): ChatRetentionInfo {
|
||||
// If `maximum_chat_retention_days` isn't set- never display retention warning.
|
||||
const chatRetentionDays = settings.maximum_chat_retention_days || 10000;
|
||||
const createdDate = new Date(chatSession.time_created);
|
||||
const updatedDate = new Date(chatSession.time_updated);
|
||||
const today = new Date();
|
||||
const daysFromCreation = Math.ceil(
|
||||
(today.getTime() - createdDate.getTime()) / (1000 * 3600 * 24)
|
||||
(today.getTime() - updatedDate.getTime()) / (1000 * 3600 * 24)
|
||||
);
|
||||
const daysUntilExpiration = chatRetentionDays - daysFromCreation;
|
||||
const showRetentionWarning =
|
||||
@@ -162,8 +162,6 @@ export async function* sendMessage({
|
||||
regenerate,
|
||||
message,
|
||||
fileDescriptors,
|
||||
userFileIds,
|
||||
userFolderIds,
|
||||
parentMessageId,
|
||||
chatSessionId,
|
||||
promptId,
|
||||
@@ -178,7 +176,6 @@ export async function* sendMessage({
|
||||
useExistingUserMessage,
|
||||
alternateAssistantId,
|
||||
signal,
|
||||
forceUserFileSearch,
|
||||
useLanggraph,
|
||||
}: {
|
||||
regenerate: boolean;
|
||||
@@ -198,9 +195,6 @@ export async function* sendMessage({
|
||||
useExistingUserMessage?: boolean;
|
||||
alternateAssistantId?: number;
|
||||
signal?: AbortSignal;
|
||||
userFileIds?: number[];
|
||||
userFolderIds?: number[];
|
||||
forceUserFileSearch?: boolean;
|
||||
useLanggraph?: boolean;
|
||||
}): AsyncGenerator<PacketType, void, unknown> {
|
||||
const documentsAreSelected =
|
||||
@@ -212,10 +206,7 @@ export async function* sendMessage({
|
||||
message: message,
|
||||
prompt_id: promptId,
|
||||
search_doc_ids: documentsAreSelected ? selectedDocumentIds : null,
|
||||
force_user_file_search: forceUserFileSearch,
|
||||
file_descriptors: fileDescriptors,
|
||||
user_file_ids: userFileIds,
|
||||
user_folder_ids: userFolderIds,
|
||||
regenerate,
|
||||
retrieval_options: !documentsAreSelected
|
||||
? {
|
||||
@@ -428,7 +419,7 @@ export function groupSessionsByDateRange(chatSessions: ChatSession[]) {
|
||||
};
|
||||
|
||||
chatSessions.forEach((chatSession) => {
|
||||
const chatSessionDate = new Date(chatSession.time_created);
|
||||
const chatSessionDate = new Date(chatSession.time_updated);
|
||||
|
||||
const diffTime = today.getTime() - chatSessionDate.getTime();
|
||||
const diffDays = diffTime / (1000 * 3600 * 24); // Convert time difference to days
|
||||
@@ -510,6 +501,7 @@ export function processRawChatHistory(
|
||||
sub_questions: subQuestions,
|
||||
isImprovement:
|
||||
(messageInfo.refined_answer_improvement as unknown as boolean) || false,
|
||||
is_agentic: messageInfo.is_agentic,
|
||||
};
|
||||
|
||||
messages.set(messageInfo.message_id, message);
|
||||
|
||||
@@ -50,6 +50,9 @@ import "katex/dist/katex.min.css";
|
||||
import SubQuestionsDisplay from "./SubQuestionsDisplay";
|
||||
import { StatusRefinement } from "../Refinement";
|
||||
import { copyAll, handleCopy } from "./copyingUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { ErrorBanner, Resubmit } from "./Resubmit";
|
||||
|
||||
export const AgenticMessage = ({
|
||||
isStreamingQuestions,
|
||||
@@ -84,7 +87,9 @@ export const AgenticMessage = ({
|
||||
secondLevelSubquestions,
|
||||
toggleDocDisplay,
|
||||
error,
|
||||
resubmit,
|
||||
}: {
|
||||
resubmit?: () => void;
|
||||
isStreamingQuestions: boolean;
|
||||
isGenerating: boolean;
|
||||
docSidebarToggled?: boolean;
|
||||
@@ -455,7 +460,6 @@ export const AgenticMessage = ({
|
||||
finalContent.length > 8) ||
|
||||
(files && files.length > 0) ? (
|
||||
<>
|
||||
{/* <FileDisplay files={files || []} /> */}
|
||||
<div className="w-full py-4 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-x-2 px-4">
|
||||
<div className="text-black text-lg font-medium">
|
||||
@@ -503,9 +507,7 @@ export const AgenticMessage = ({
|
||||
content
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-2 text-red-700 text-sm my-auto">
|
||||
{error}
|
||||
</p>
|
||||
<ErrorBanner error={error} resubmit={resubmit} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -513,15 +515,13 @@ export const AgenticMessage = ({
|
||||
) : isComplete ? (
|
||||
error && (
|
||||
<p className="mt-2 mx-4 text-red-700 text-sm my-auto">
|
||||
{error}
|
||||
<ErrorBanner error={error} resubmit={resubmit} />
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p className="mt-2 mx-4 text-red-700 text-sm my-auto">
|
||||
{error}
|
||||
</p>
|
||||
<ErrorBanner error={error} resubmit={resubmit} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
58
web/src/app/chat/message/Resubmit.tsx
Normal file
58
web/src/app/chat/message/Resubmit.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface ResubmitProps {
|
||||
resubmit: () => void;
|
||||
}
|
||||
|
||||
export const Resubmit: React.FC<ResubmitProps> = ({ resubmit }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-y-2 mt-4">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
There was an error with the response.
|
||||
</p>
|
||||
<Button
|
||||
onClick={resubmit}
|
||||
variant="agent"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-white font-medium py-2 px-4 rounded"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorBanner = ({
|
||||
error,
|
||||
showStackTrace,
|
||||
resubmit,
|
||||
}: {
|
||||
error: string;
|
||||
showStackTrace?: () => void;
|
||||
resubmit?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="text-red-700 mt-4 text-sm my-auto">
|
||||
<Alert variant="broken">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription className="flex gap-x-2">
|
||||
{error}
|
||||
{showStackTrace && (
|
||||
<span
|
||||
className="text-red-600 hover:text-red-800 cursor-pointer underline"
|
||||
onClick={showStackTrace}
|
||||
>
|
||||
Show stack trace
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{resubmit && <Resubmit resubmit={resubmit} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -40,8 +40,13 @@ export function UserSettingsModal({
|
||||
onClose: () => void;
|
||||
defaultModel: string | null;
|
||||
}) {
|
||||
const { refreshUser, user, updateUserAutoScroll, updateUserShortcuts } =
|
||||
useUser();
|
||||
const {
|
||||
refreshUser,
|
||||
user,
|
||||
updateUserAutoScroll,
|
||||
updateUserShortcuts,
|
||||
updateUserTemperatureOverrideEnabled,
|
||||
} = useUser();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const messageRef = useRef<HTMLDivElement>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -156,11 +161,6 @@ export function UserSettingsModal({
|
||||
const settings = useContext(SettingsContext);
|
||||
const autoScroll = settings?.settings?.auto_scroll;
|
||||
|
||||
const checked =
|
||||
user?.preferences?.auto_scroll === null
|
||||
? autoScroll
|
||||
: user?.preferences?.auto_scroll;
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
@@ -288,12 +288,26 @@ export function UserSettingsModal({
|
||||
<SubLabel>Automatically scroll to new content</SubLabel>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
checked={user?.preferences.auto_scroll}
|
||||
onCheckedChange={(checked) => {
|
||||
updateUserAutoScroll(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">
|
||||
Temperature override
|
||||
</h3>
|
||||
<SubLabel>Set the temperature for the LLM</SubLabel>
|
||||
</div>
|
||||
<Switch
|
||||
checked={user?.preferences.temperature_override_enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
updateUserTemperatureOverrideEnabled(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Prompt Shortcuts</h3>
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
"use client";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import * as documentsService from "@/services/documentsService";
|
||||
|
||||
export interface FolderResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
files: FileResponse[];
|
||||
assistant_ids?: number[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type FileResponse = {
|
||||
id: number;
|
||||
name: string;
|
||||
document_id: string;
|
||||
folder_id: number | null;
|
||||
size?: number;
|
||||
type?: string;
|
||||
lastModified?: string;
|
||||
token_count?: number;
|
||||
assistant_ids?: number[];
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
export interface FileUploadResponse {
|
||||
file_paths: string[];
|
||||
}
|
||||
|
||||
export interface DocumentsContextType {
|
||||
folders: FolderResponse[];
|
||||
currentFolder: number | null;
|
||||
presentingDocument: MinimalOnyxDocument | null;
|
||||
searchQuery: string;
|
||||
page: number;
|
||||
refreshFolders: () => Promise<void>;
|
||||
createFolder: (name: string, description: string) => Promise<FolderResponse>;
|
||||
deleteItem: (itemId: number, isFolder: boolean) => Promise<void>;
|
||||
moveItem: (
|
||||
itemId: number,
|
||||
currentFolderId: number | null,
|
||||
isFolder: boolean
|
||||
) => Promise<void>;
|
||||
downloadItem: (documentId: string) => Promise<void>;
|
||||
renameItem: (
|
||||
itemId: number,
|
||||
currentName: string,
|
||||
isFolder: boolean
|
||||
) => Promise<void>;
|
||||
setCurrentFolder: (folderId: number | null) => void;
|
||||
setPresentingDocument: (document: MinimalOnyxDocument | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setPage: (page: number) => void;
|
||||
getFolderDetails: (folderId: number) => Promise<FolderResponse>;
|
||||
updateFolderDetails: (
|
||||
folderId: number,
|
||||
name: string,
|
||||
description: string
|
||||
) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
uploadFile: (
|
||||
formData: FormData,
|
||||
folderId: number | null
|
||||
) => Promise<FileUploadResponse>;
|
||||
selectedFiles: FileResponse[];
|
||||
selectedFolders: FolderResponse[];
|
||||
addSelectedFile: (file: FileResponse) => void;
|
||||
removeSelectedFile: (file: FileResponse) => void;
|
||||
addSelectedFolder: (folder: FolderResponse) => void;
|
||||
removeSelectedFolder: (folder: FolderResponse) => void;
|
||||
clearSelectedItems: () => void;
|
||||
createFileFromLink: (
|
||||
url: string,
|
||||
folderId: number | null
|
||||
) => Promise<FileUploadResponse>;
|
||||
setSelectedFiles: Dispatch<SetStateAction<FileResponse[]>>;
|
||||
setSelectedFolders: Dispatch<SetStateAction<FolderResponse[]>>;
|
||||
handleUpload: (files: File[]) => Promise<void>;
|
||||
handleCreateFileFromLink: () => Promise<void>;
|
||||
refreshFolderDetails: () => Promise<void>;
|
||||
folderDetails: FolderResponse | undefined | null;
|
||||
setFolderDetails: Dispatch<SetStateAction<FolderResponse | undefined | null>>;
|
||||
showUploadWarning: boolean;
|
||||
setShowUploadWarning: Dispatch<SetStateAction<boolean>>;
|
||||
linkUrl: string;
|
||||
setLinkUrl: Dispatch<SetStateAction<string>>;
|
||||
isCreatingFileFromLink: boolean;
|
||||
setIsCreatingFileFromLink: Dispatch<SetStateAction<boolean>>;
|
||||
error: string | null;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
getFolders: () => Promise<FolderResponse[]>;
|
||||
}
|
||||
|
||||
const DocumentsContext = createContext<DocumentsContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface DocumentsProviderProps {
|
||||
children: ReactNode;
|
||||
initialFolderDetails?: FolderResponse | null;
|
||||
}
|
||||
|
||||
export const DocumentsProvider: React.FC<DocumentsProviderProps> = ({
|
||||
children,
|
||||
initialFolderDetails,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [folders, setFolders] = useState<FolderResponse[]>([]);
|
||||
const [currentFolder, setCurrentFolder] = useState<number | null>(null);
|
||||
const [presentingDocument, setPresentingDocument] =
|
||||
useState<MinimalOnyxDocument | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedFiles, setSelectedFiles] = useState<FileResponse[]>([]);
|
||||
const [selectedFolders, setSelectedFolders] = useState<FolderResponse[]>([]);
|
||||
const [folderDetails, setFolderDetails] = useState<
|
||||
FolderResponse | undefined | null
|
||||
>(initialFolderDetails || null);
|
||||
const [showUploadWarning, setShowUploadWarning] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [isCreatingFileFromLink, setIsCreatingFileFromLink] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFolders = async () => {
|
||||
await refreshFolders();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchFolders();
|
||||
}, []);
|
||||
|
||||
const refreshFolders = useCallback(async () => {
|
||||
try {
|
||||
const data = await documentsService.fetchFolders();
|
||||
setFolders(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folders:", error);
|
||||
setError("Failed to fetch folders");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (
|
||||
formData: FormData,
|
||||
folderId: number | null
|
||||
): Promise<FileUploadResponse> => {
|
||||
if (folderId) {
|
||||
formData.append("folder_id", folderId.toString());
|
||||
}
|
||||
try {
|
||||
const data = await documentsService.uploadFileRequest(formData);
|
||||
await refreshFolders();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to upload file:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[refreshFolders]
|
||||
);
|
||||
|
||||
const createFolder = useCallback(
|
||||
async (name: string, description: string) => {
|
||||
try {
|
||||
const newFolder = await documentsService.createNewFolder(
|
||||
name,
|
||||
description
|
||||
);
|
||||
await refreshFolders();
|
||||
return newFolder;
|
||||
} catch (error) {
|
||||
console.error("Failed to create folder:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[refreshFolders]
|
||||
);
|
||||
|
||||
const deleteItem = useCallback(
|
||||
async (itemId: number, isFolder: boolean) => {
|
||||
try {
|
||||
if (isFolder) {
|
||||
await documentsService.deleteFolder(itemId);
|
||||
} else {
|
||||
await documentsService.deleteFile(itemId);
|
||||
}
|
||||
await refreshFolders();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete item:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[refreshFolders]
|
||||
);
|
||||
|
||||
const moveItem = useCallback(
|
||||
async (
|
||||
itemId: number,
|
||||
currentFolderId: number | null,
|
||||
isFolder: boolean
|
||||
) => {
|
||||
try {
|
||||
await documentsService.moveItem(itemId, currentFolderId, isFolder);
|
||||
await refreshFolders();
|
||||
} catch (error) {
|
||||
console.error("Failed to move item:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[refreshFolders]
|
||||
);
|
||||
|
||||
const downloadItem = useCallback(async (documentId: string) => {
|
||||
try {
|
||||
const blob = await documentsService.downloadItem(documentId);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "document";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Failed to download item:", error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renameItem = useCallback(
|
||||
async (itemId: number, newName: string, isFolder: boolean) => {
|
||||
try {
|
||||
await documentsService.renameItem(itemId, newName, isFolder);
|
||||
if (isFolder) {
|
||||
await refreshFolders();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rename item:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[refreshFolders]
|
||||
);
|
||||
|
||||
const getFolderDetails = useCallback(async (folderId: number) => {
|
||||
try {
|
||||
return await documentsService.getFolderDetails(folderId);
|
||||
} catch (error) {
|
||||
console.error("Failed to get folder details:", error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateFolderDetails = useCallback(
|
||||
async (folderId: number, name: string, description: string) => {
|
||||
try {
|
||||
await documentsService.updateFolderDetails(folderId, name, description);
|
||||
await refreshFolders();
|
||||
} catch (error) {
|
||||
console.error("Failed to update folder details:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[refreshFolders]
|
||||
);
|
||||
|
||||
const addSelectedFile = useCallback((file: FileResponse) => {
|
||||
setSelectedFiles((prev) => [...prev, file]);
|
||||
}, []);
|
||||
|
||||
const removeSelectedFile = useCallback((file: FileResponse) => {
|
||||
setSelectedFiles((prev) => prev.filter((f) => f.id !== file.id));
|
||||
}, []);
|
||||
|
||||
const addSelectedFolder = useCallback((folder: FolderResponse) => {
|
||||
setSelectedFolders((prev) => {
|
||||
if (prev.find((f) => f.id === folder.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, folder];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeSelectedFolder = useCallback((folder: FolderResponse) => {
|
||||
setSelectedFolders((prev) => prev.filter((f) => f.id !== folder.id));
|
||||
}, []);
|
||||
|
||||
const clearSelectedItems = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
setSelectedFolders([]);
|
||||
}, []);
|
||||
|
||||
const refreshFolderDetails = useCallback(async () => {
|
||||
if (folderDetails) {
|
||||
const details = await getFolderDetails(folderDetails.id);
|
||||
setFolderDetails(details);
|
||||
}
|
||||
}, [folderDetails, getFolderDetails]);
|
||||
|
||||
const createFileFromLink = useCallback(
|
||||
async (
|
||||
url: string,
|
||||
folderId: number | null
|
||||
): Promise<FileUploadResponse> => {
|
||||
try {
|
||||
const data = await documentsService.createFileFromLinkRequest(
|
||||
url,
|
||||
folderId
|
||||
);
|
||||
await refreshFolders();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to create file from link:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[refreshFolders]
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (
|
||||
folderDetails?.assistant_ids &&
|
||||
folderDetails.assistant_ids.length > 0
|
||||
) {
|
||||
setShowUploadWarning(true);
|
||||
} else {
|
||||
await performUpload(files);
|
||||
}
|
||||
},
|
||||
[folderDetails]
|
||||
);
|
||||
|
||||
const performUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append("files", file);
|
||||
});
|
||||
setIsLoading(true);
|
||||
|
||||
await uploadFile(formData, folderDetails?.id || null);
|
||||
await refreshFolderDetails();
|
||||
} catch (error) {
|
||||
console.error("Error uploading documents:", error);
|
||||
setError("Failed to upload documents. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setShowUploadWarning(false);
|
||||
}
|
||||
},
|
||||
[uploadFile, folderDetails, refreshFolderDetails]
|
||||
);
|
||||
|
||||
const handleCreateFileFromLink = useCallback(async () => {
|
||||
if (!linkUrl) return;
|
||||
setIsCreatingFileFromLink(true);
|
||||
try {
|
||||
await createFileFromLink(linkUrl, folderDetails?.id || null);
|
||||
setLinkUrl("");
|
||||
await refreshFolderDetails();
|
||||
} catch (error) {
|
||||
console.error("Error creating file from link:", error);
|
||||
setError("Failed to create file from link. Please try again.");
|
||||
} finally {
|
||||
setIsCreatingFileFromLink(false);
|
||||
}
|
||||
}, [linkUrl, createFileFromLink, folderDetails, refreshFolderDetails]);
|
||||
|
||||
const getFolders = async (): Promise<FolderResponse[]> => {
|
||||
try {
|
||||
const response = await fetch("/api/user/folder");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch folders");
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error fetching folders:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const value: DocumentsContextType = {
|
||||
folderDetails,
|
||||
setFolderDetails,
|
||||
folders,
|
||||
currentFolder,
|
||||
presentingDocument,
|
||||
searchQuery,
|
||||
page,
|
||||
refreshFolders,
|
||||
createFolder,
|
||||
deleteItem,
|
||||
moveItem,
|
||||
downloadItem,
|
||||
renameItem,
|
||||
setCurrentFolder,
|
||||
setPresentingDocument,
|
||||
setSearchQuery,
|
||||
setPage,
|
||||
getFolderDetails,
|
||||
updateFolderDetails,
|
||||
isLoading,
|
||||
uploadFile,
|
||||
selectedFiles,
|
||||
selectedFolders,
|
||||
addSelectedFile,
|
||||
removeSelectedFile,
|
||||
addSelectedFolder,
|
||||
removeSelectedFolder,
|
||||
clearSelectedItems,
|
||||
createFileFromLink,
|
||||
setSelectedFiles,
|
||||
setSelectedFolders,
|
||||
handleUpload,
|
||||
handleCreateFileFromLink,
|
||||
refreshFolderDetails,
|
||||
showUploadWarning,
|
||||
setShowUploadWarning,
|
||||
linkUrl,
|
||||
setLinkUrl,
|
||||
isCreatingFileFromLink,
|
||||
setIsCreatingFileFromLink,
|
||||
error,
|
||||
setError,
|
||||
getFolders,
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentsContext.Provider value={value}>
|
||||
{children}
|
||||
</DocumentsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useDocumentsContext = () => {
|
||||
const context = useContext(DocumentsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDocuments must be used within a DocumentsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,342 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
FolderIcon,
|
||||
FileIcon,
|
||||
DownloadIcon,
|
||||
TrashIcon,
|
||||
PencilIcon,
|
||||
InfoIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface FolderItemProps {
|
||||
folder: { name: string; id: number };
|
||||
onFolderClick: (folderId: number) => void;
|
||||
onDeleteItem: (itemId: number, isFolder: boolean) => void;
|
||||
onMoveItem: (folderId: number) => void;
|
||||
editingItem: { id: number; name: string; isFolder: boolean } | null;
|
||||
setEditingItem: React.Dispatch<
|
||||
React.SetStateAction<{ id: number; name: string; isFolder: boolean } | null>
|
||||
>;
|
||||
handleRename: (id: number, newName: string, isFolder: boolean) => void;
|
||||
onDragStart: (
|
||||
e: React.DragEvent<HTMLDivElement>,
|
||||
item: { id: number; isFolder: boolean; name: string }
|
||||
) => void;
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetFolderId: number) => void;
|
||||
}
|
||||
|
||||
export function FolderItem({
|
||||
folder,
|
||||
onFolderClick,
|
||||
onDeleteItem,
|
||||
onMoveItem,
|
||||
editingItem,
|
||||
setEditingItem,
|
||||
handleRename,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
}: FolderItemProps) {
|
||||
const [showMenu, setShowMenu] = useState<undefined | number>(undefined);
|
||||
const [newName, setNewName] = useState(folder.name);
|
||||
|
||||
const isEditing =
|
||||
editingItem && editingItem.id === folder.id && editingItem.isFolder;
|
||||
|
||||
const folderItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const xPos =
|
||||
e.clientX - folderItemRef.current?.getBoundingClientRect().left! - 40;
|
||||
setShowMenu(xPos);
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setEditingItem({ id: folder.id, name: folder.name, isFolder: true });
|
||||
setNewName(folder.name);
|
||||
setShowMenu(undefined);
|
||||
};
|
||||
|
||||
const submitRename = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleRename(folder.id, newName, true);
|
||||
};
|
||||
|
||||
const cancelEditing = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingItem(null);
|
||||
setNewName(folder.name);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("click", (e) => {
|
||||
setShowMenu(undefined);
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("click", () => {});
|
||||
};
|
||||
}, [showMenu]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={folderItemRef}
|
||||
className="flex items-center justify-between p-2 hover:bg-background-100 cursor-pointer relative"
|
||||
onClick={() => !isEditing && onFolderClick(folder.id)}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) =>
|
||||
onDragStart(e, { id: folder.id, isFolder: true, name: folder.name })
|
||||
}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => onDrop(e, folder.id)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="mr-2" />
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setNewName(e.target.value);
|
||||
}}
|
||||
className="border rounded px-2 py-1 mr-2"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={submitRename}
|
||||
className="text-green-500 hover:text-green-700 mr-2"
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span>{folder.name}</span>
|
||||
)}
|
||||
</div>
|
||||
{showMenu && !isEditing && (
|
||||
<div
|
||||
className="absolute bg-white border rounded shadow py-1 right-0 top-full mt-1 z-50"
|
||||
style={{ left: showMenu }}
|
||||
>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveItem(folder.id);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteItem(folder.id, true);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileItemProps {
|
||||
file: { name: string; id: number; document_id: string };
|
||||
onDeleteItem: (itemId: number, isFolder: boolean) => void;
|
||||
onDownloadItem: (documentId: string) => void;
|
||||
onMoveItem: (fileId: number) => void;
|
||||
editingItem: { id: number; name: string; isFolder: boolean } | null;
|
||||
setEditingItem: React.Dispatch<
|
||||
React.SetStateAction<{ id: number; name: string; isFolder: boolean } | null>
|
||||
>;
|
||||
setPresentingDocument: (
|
||||
document_id: string,
|
||||
semantic_identifier: string
|
||||
) => void;
|
||||
handleRename: (fileId: number, newName: string, isFolder: boolean) => void;
|
||||
onDragStart: (
|
||||
e: React.DragEvent<HTMLDivElement>,
|
||||
item: { id: number; isFolder: boolean; name: string }
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function FileItem({
|
||||
setPresentingDocument,
|
||||
file,
|
||||
onDeleteItem,
|
||||
onDownloadItem,
|
||||
onMoveItem,
|
||||
editingItem,
|
||||
setEditingItem,
|
||||
handleRename,
|
||||
onDragStart,
|
||||
}: FileItemProps) {
|
||||
const [showMenu, setShowMenu] = useState<undefined | number>();
|
||||
const [newFileName, setNewFileName] = useState(file.name);
|
||||
|
||||
const isEditing =
|
||||
editingItem && editingItem.id === file.id && !editingItem.isFolder;
|
||||
|
||||
const fileItemRef = useRef<HTMLDivElement>(null);
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const xPos =
|
||||
e.clientX - fileItemRef.current?.getBoundingClientRect().left! - 40;
|
||||
setShowMenu(xPos);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("click", (e) => {
|
||||
if (fileItemRef.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setShowMenu(undefined);
|
||||
});
|
||||
document.addEventListener("contextmenu", (e) => {
|
||||
if (fileItemRef.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setShowMenu(undefined);
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("click", () => {});
|
||||
document.removeEventListener("contextmenu", () => {});
|
||||
};
|
||||
}, [showMenu]);
|
||||
|
||||
const startEditing = () => {
|
||||
setEditingItem({ id: file.id, name: file.name, isFolder: false });
|
||||
setNewFileName(file.name);
|
||||
setShowMenu(undefined);
|
||||
};
|
||||
|
||||
const submitRename = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleRename(file.id, newFileName, false);
|
||||
};
|
||||
|
||||
const cancelEditing = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingItem(null);
|
||||
setNewFileName(file.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileItemRef}
|
||||
key={file.id}
|
||||
className="flex items-center w-full justify-between p-2 hover:bg-background-100 cursor-pointer relative"
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) =>
|
||||
onDragStart(e, { id: file.id, isFolder: false, name: file.name })
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => setPresentingDocument(file.document_id, file.name)}
|
||||
className="flex items-center flex-grow"
|
||||
>
|
||||
<FileIcon className="mr-2" />
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
type="text"
|
||||
value={newFileName}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setNewFileName(e.target.value);
|
||||
}}
|
||||
className="border rounded px-2 py-1 mr-2"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={submitRename}
|
||||
className="text-green-500 hover:text-green-700 mr-2"
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex text-wrap text-left line-clamp-2">{file.name}</p>
|
||||
)}
|
||||
</button>
|
||||
{showMenu && !isEditing && (
|
||||
<div
|
||||
className="absolute bg-white max-w-40 border rounded shadow py-1 right-0 top-full mt-1 z-50"
|
||||
style={{ left: showMenu }}
|
||||
>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadItem(file.document_id);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveItem(file.id);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Moveewsd
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteItem(file.id, false);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Search, Plus, FolderOpen, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { PageSelector } from "@/components/PageSelector";
|
||||
import { SharedFolderItem } from "./components/SharedFolderItem";
|
||||
import CreateEntityModal from "@/components/modals/CreateEntityModal";
|
||||
import { useDocumentsContext } from "./DocumentsContext";
|
||||
import { SortIcon } from "@/components/icons/icons";
|
||||
import TextView from "@/components/chat/TextView";
|
||||
|
||||
enum SortType {
|
||||
TimeCreated = "Time Created",
|
||||
Alphabetical = "Alphabetical",
|
||||
}
|
||||
|
||||
interface SortSelectorProps {
|
||||
onSortChange: (sortType: SortType) => void;
|
||||
}
|
||||
|
||||
const SortSelector: React.FC<SortSelectorProps> = ({ onSortChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentSort, setCurrentSort] = useState<SortType>(
|
||||
SortType.TimeCreated
|
||||
);
|
||||
|
||||
const handleSortChange = (sortType: SortType) => {
|
||||
setCurrentSort(sortType);
|
||||
onSortChange(sortType);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-fit">
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full w-48 bg-white rounded-md shadow-lg z-10">
|
||||
<div className="py-1">
|
||||
{Object.values(SortType).map((sortType) => (
|
||||
<button
|
||||
key={sortType}
|
||||
onClick={() => handleSortChange(sortType)}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 focus:outline-none"
|
||||
>
|
||||
{sortType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center space-x-2 text-sm text-neutral-600 hover:text-neutral-800 focus:outline-none"
|
||||
>
|
||||
<span>{currentSort}</span>
|
||||
<SortIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonLoader = ({ count = 5 }) => (
|
||||
<div className={`mt-4 grid gap-3 md:mt-8 md:grid-cols-2 md:gap-6`}>
|
||||
{[...Array(count)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="animate-pulse bg-background-200 rounded-xl h-24"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function MyDocuments() {
|
||||
const {
|
||||
folders,
|
||||
currentFolder,
|
||||
presentingDocument,
|
||||
searchQuery,
|
||||
page,
|
||||
refreshFolders,
|
||||
createFolder,
|
||||
deleteItem,
|
||||
moveItem,
|
||||
isLoading,
|
||||
downloadItem,
|
||||
renameItem,
|
||||
setCurrentFolder,
|
||||
setPresentingDocument,
|
||||
setSearchQuery,
|
||||
setPage,
|
||||
} = useDocumentsContext();
|
||||
|
||||
const [sortType, setSortType] = useState<SortType>(SortType.TimeCreated);
|
||||
const handleSortChange = (sortType: SortType) => {
|
||||
setSortType(sortType);
|
||||
};
|
||||
const pageLimit = 10;
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { popup, setPopup } = usePopup();
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const folderIdFromParams = parseInt(searchParams.get("folder") || "0", 10);
|
||||
|
||||
const handleFolderClick = (id: number) => {
|
||||
startTransition(() => {
|
||||
router.push(`/chat/my-documents/${id}`);
|
||||
setPage(1);
|
||||
setCurrentFolder(id);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateFolder = async (name: string, description: string) => {
|
||||
try {
|
||||
const folderResponse = await createFolder(name, description);
|
||||
// setPopup({
|
||||
// message: "Folder created successfully",
|
||||
// type: "success",
|
||||
// });
|
||||
// await refreshFolders();
|
||||
// setIsCreateFolderOpen(false);
|
||||
startTransition(() => {
|
||||
router.push(
|
||||
`/chat/my-documents/${folderResponse.id}?message=folder-created`
|
||||
);
|
||||
setPage(1);
|
||||
setCurrentFolder(folderResponse.id);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating folder:", error);
|
||||
setPopup({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create knowledge group",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (itemId: number, isFolder: boolean) => {
|
||||
const itemType = isFolder ? "Knowledge Group" : "File";
|
||||
const confirmDelete = window.confirm(
|
||||
`Are you sure you want to delete this ${itemType}?`
|
||||
);
|
||||
|
||||
if (confirmDelete) {
|
||||
try {
|
||||
await deleteItem(itemId, isFolder);
|
||||
setPopup({
|
||||
message: `${itemType} deleted successfully`,
|
||||
type: "success",
|
||||
});
|
||||
await refreshFolders();
|
||||
} catch (error) {
|
||||
console.error("Error deleting item:", error);
|
||||
setPopup({
|
||||
message: `Failed to delete ${itemType}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveItem = async (
|
||||
itemId: number,
|
||||
currentFolderId: number | null,
|
||||
isFolder: boolean
|
||||
) => {
|
||||
const availableFolders = folders
|
||||
.filter((folder) => folder.id !== itemId)
|
||||
.map((folder) => `${folder.id}: ${folder.name}`)
|
||||
.join("\n");
|
||||
|
||||
const promptMessage = `Enter the ID of the destination folder:\n\nAvailable folders:\n${availableFolders}\n\nEnter 0 to move to the root folder.`;
|
||||
const destinationFolderId = prompt(promptMessage);
|
||||
|
||||
if (destinationFolderId !== null) {
|
||||
const newFolderId = parseInt(destinationFolderId, 10);
|
||||
if (isNaN(newFolderId)) {
|
||||
setPopup({
|
||||
message: "Invalid folder ID",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await moveItem(
|
||||
itemId,
|
||||
newFolderId === 0 ? null : newFolderId,
|
||||
isFolder
|
||||
);
|
||||
setPopup({
|
||||
message: `${
|
||||
isFolder ? "Knowledge Group" : "File"
|
||||
} moved successfully`,
|
||||
type: "success",
|
||||
});
|
||||
await refreshFolders();
|
||||
} catch (error) {
|
||||
console.error("Error moving item:", error);
|
||||
setPopup({
|
||||
message: "Failed to move item",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadItem = async (documentId: string) => {
|
||||
try {
|
||||
await downloadItem(documentId);
|
||||
} catch (error) {
|
||||
console.error("Error downloading file:", error);
|
||||
setPopup({
|
||||
message: "Failed to download file",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRenameItem = async (
|
||||
itemId: number,
|
||||
currentName: string,
|
||||
isFolder: boolean
|
||||
) => {
|
||||
const newName = prompt(
|
||||
`Enter new name for ${isFolder ? "Knowledge Group" : "File"}:`,
|
||||
currentName
|
||||
);
|
||||
if (newName && newName !== currentName) {
|
||||
try {
|
||||
await renameItem(itemId, newName, isFolder);
|
||||
setPopup({
|
||||
message: `${
|
||||
isFolder ? "Knowledge Group" : "File"
|
||||
} renamed successfully`,
|
||||
type: "success",
|
||||
});
|
||||
await refreshFolders();
|
||||
} catch (error) {
|
||||
console.error("Error renaming item:", error);
|
||||
setPopup({
|
||||
message: `Failed to rename ${isFolder ? "Knowledge Group" : "File"}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFolders = useMemo(() => {
|
||||
return folders
|
||||
.filter(
|
||||
(folder) =>
|
||||
folder.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
folder.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sortType === SortType.TimeCreated) {
|
||||
return (
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
} else if (sortType === SortType.Alphabetical) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [folders, searchQuery, sortType]);
|
||||
|
||||
return (
|
||||
<div className="min-h-full w-full min-w-0 flex-1 mx-auto mt-4 w-full max-w-5xl flex-1 px-4 pb-20 md:pl-8 lg:mt-6 md:pr-8 2xl:pr-14">
|
||||
<header className="flex w-full items-center justify-between gap-4 pt-2 -translate-y-px">
|
||||
<h1 className=" flex items-center gap-1.5 text-lg font-medium leading-tight tracking-tight max-md:hidden">
|
||||
Knowledge Groups
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreateEntityModal
|
||||
title="Create New Knowledge Group"
|
||||
entityName="Knowledge Group"
|
||||
open={isCreateFolderOpen}
|
||||
setOpen={setIsCreateFolderOpen}
|
||||
onSubmit={handleCreateFolder}
|
||||
trigger={
|
||||
<Button className="inline-flex items-center justify-center relative shrink-0 h-9 px-4 py-2 rounded-lg min-w-[5rem] active:scale-[0.985] whitespace-nowrap pl-2 pr-3 gap-1">
|
||||
<Plus className="h-5 w-5" />
|
||||
New Group
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<main className="w-full mt-4">
|
||||
<div className=" top-3 w-full z-[5] flex gap-4 bg-gradient-to-b via-50% max-lg:flex-col lg:sticky lg:items-center">
|
||||
<div className="flex justify-between w-full ">
|
||||
<div className="bg-background-000 dark:bg-neutral-800 border md:max-w-96 border-border-200 dark:border-neutral-700 hover:border-border-100 dark:hover:border-neutral-600 transition-colors placeholder:text-text-500 dark:placeholder:text-neutral-400 focus:border-accent-secondary-100 focus-within:!border-accent-secondary-100 focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 h-11 px-3 rounded-[0.6rem] w-full inline-flex cursor-text items-stretch gap-2">
|
||||
<div className="flex items-center">
|
||||
<Search className="h-4 w-4 text-text-400 dark:text-neutral-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search groups..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full placeholder:text-text-500 dark:placeholder:text-neutral-400 m-0 bg-transparent p-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<SortSelector onSortChange={handleSortChange} />
|
||||
</div>
|
||||
</div>
|
||||
{isPending && (
|
||||
<div className="flex fixed left-20 top-1/3 justify-center items-center mt-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary dark:text-neutral-300" />
|
||||
</div>
|
||||
)}
|
||||
{presentingDocument && (
|
||||
<TextView
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
)}
|
||||
{popup}
|
||||
<div className="flex-grow">
|
||||
{isLoading ? (
|
||||
<SkeletonLoader />
|
||||
) : filteredFolders.length > 0 ? (
|
||||
<div
|
||||
className={`mt-4 grid gap-3 md:mt-8 ${
|
||||
true ? "md:grid-cols-2" : ""
|
||||
} md:gap-6 transition-all duration-300 ease-in-out`}
|
||||
>
|
||||
{filteredFolders.map((folder) => (
|
||||
<SharedFolderItem
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onClick={handleFolderClick}
|
||||
description={folder.description}
|
||||
lastUpdated={folder.created_at}
|
||||
onRename={() => onRenameItem(folder.id, folder.name, true)}
|
||||
onDelete={() => handleDeleteItem(folder.id, true)}
|
||||
onMove={() => handleMoveItem(folder.id, currentFolder, true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<FolderOpen
|
||||
className="w-20 h-20 text-orange-400 dark:text-orange-300 mb-4 "
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<p className="text-text-500 dark:text-neutral-400 text-lg font-normal">
|
||||
No items found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex">
|
||||
<div className="mx-auto">
|
||||
<PageSelector
|
||||
currentPage={page}
|
||||
totalPages={Math.ceil((folders?.length || 0) / pageLimit)}
|
||||
onPageChange={(newPage) => {
|
||||
setPage(newPage);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Title from "@/components/ui/title";
|
||||
import SidebarWrapper from "../../assistants/SidebarWrapper";
|
||||
import MyDocuments from "./MyDocuments";
|
||||
|
||||
export default function WrappedUserDocuments({}: {}) {
|
||||
return (
|
||||
<SidebarWrapper size="lg">
|
||||
<div className="mx-auto w-full">
|
||||
<MyDocuments />
|
||||
</div>
|
||||
</SidebarWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { useDocumentsContext } from "../DocumentsContext";
|
||||
|
||||
export default function UserFolder({ userFileId }: { userFileId: string }) {
|
||||
const { folders } = useDocumentsContext();
|
||||
|
||||
return <div>{folders.length}</div>;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import SidebarWrapper from "@/app/assistants/SidebarWrapper";
|
||||
import UserFolderContent from "./UserFolderContent";
|
||||
|
||||
export default function WrappedUserFolders({
|
||||
userFileId,
|
||||
}: {
|
||||
userFileId: string;
|
||||
}) {
|
||||
return (
|
||||
<SidebarWrapper size="lg">
|
||||
<div className="mx-auto w-full">
|
||||
<UserFolderContent folderId={Number(userFileId)} />
|
||||
</div>
|
||||
</SidebarWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, MessageSquare } from "lucide-react";
|
||||
import { useDocumentsContext } from "../DocumentsContext";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DocumentList } from "./components/DocumentList";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { usePopupFromQuery } from "@/components/popup/PopupFromQuery";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DeleteEntityModal } from "@/components/DeleteEntityModal";
|
||||
import { MoveFolderModal } from "@/components/MoveFolderModal";
|
||||
import { FolderResponse } from "../DocumentsContext";
|
||||
import { SharingPanel } from "./components/panels/SharingPanel";
|
||||
import { ContextLimitPanel } from "./components/panels/ContextLimitPanel";
|
||||
import { AddWebsitePanel } from "./components/panels/AddWebsitePanel";
|
||||
|
||||
export default function UserFolderContent({ folderId }: { folderId: number }) {
|
||||
const router = useRouter();
|
||||
const { assistants } = useAssistants();
|
||||
const { llmProviders } = useChatContext();
|
||||
const { popup, setPopup } = usePopup();
|
||||
const {
|
||||
folderDetails,
|
||||
getFolderDetails,
|
||||
downloadItem,
|
||||
renameItem,
|
||||
deleteItem,
|
||||
createFileFromLink,
|
||||
handleUpload,
|
||||
refreshFolderDetails,
|
||||
getFolders,
|
||||
moveItem,
|
||||
} = useDocumentsContext();
|
||||
|
||||
const [isCapacityOpen, setIsCapacityOpen] = useState(false);
|
||||
const [isSharedOpen, setIsSharedOpen] = useState(false);
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
||||
const [newItemName, setNewItemName] = useState("");
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteItemId, setDeleteItemId] = useState<number | null>(null);
|
||||
const [deleteItemType, setDeleteItemType] = useState<"file" | "folder">(
|
||||
"file"
|
||||
);
|
||||
const [deleteItemName, setDeleteItemName] = useState("");
|
||||
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
|
||||
const [folders, setFolders] = useState<FolderResponse[]>([]);
|
||||
|
||||
const modelDescriptors = llmProviders.flatMap((provider) =>
|
||||
Object.entries(provider.model_token_limits ?? {}).map(
|
||||
([modelName, maxTokens]) => ({
|
||||
modelName,
|
||||
provider: provider.provider,
|
||||
maxTokens,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState(modelDescriptors[0]);
|
||||
|
||||
const { popup: folderCreatedPopup } = usePopupFromQuery({
|
||||
"folder-created": {
|
||||
message: `Folder created successfully`,
|
||||
type: "success",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!folderDetails) {
|
||||
getFolderDetails(folderId);
|
||||
}
|
||||
}, [folderId, folderDetails, getFolderDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFolders = async () => {
|
||||
try {
|
||||
const fetchedFolders = await getFolders();
|
||||
setFolders(fetchedFolders);
|
||||
} catch (error) {
|
||||
console.error("Error fetching folders:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFolders();
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/chat/my-documents");
|
||||
};
|
||||
if (!folderDetails) {
|
||||
return (
|
||||
<div className="min-h-full w-full min-w-0 flex-1 mx-auto max-w-5xl px-4 pb-20 md:pl-8 mt-6 md:pr-8 2xl:pr-14">
|
||||
<div className="text-left space-y-4">
|
||||
<h2 className="flex items-center gap-1.5 text-lg font-medium leading-tight tracking-tight max-md:hidden">
|
||||
No Folder Found
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
The requested folder does not exist or you dont have permission to
|
||||
view it.
|
||||
</p>
|
||||
<Button onClick={handleBack} variant="outline" className="mt-2">
|
||||
Back to My Documents
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalTokens = folderDetails.files.reduce(
|
||||
(acc, file) => acc + (file.token_count || 0),
|
||||
0
|
||||
);
|
||||
const maxTokens = selectedModel.maxTokens;
|
||||
const tokenPercentage = (totalTokens / maxTokens) * 100;
|
||||
|
||||
const handleStartChat = () => {
|
||||
router.push(`/chat?userFolderId=${folderId}`);
|
||||
};
|
||||
|
||||
const handleCreateFileFromLink = async (url: string) => {
|
||||
await createFileFromLink(url, folderId);
|
||||
};
|
||||
|
||||
const handleRenameItem = async (
|
||||
itemId: number,
|
||||
currentName: string,
|
||||
isFolder: boolean
|
||||
) => {
|
||||
setEditingItemId(itemId);
|
||||
setNewItemName(currentName);
|
||||
};
|
||||
|
||||
const handleSaveRename = async (itemId: number, isFolder: boolean) => {
|
||||
if (newItemName && newItemName !== folderDetails.name) {
|
||||
try {
|
||||
await renameItem(itemId, newItemName, isFolder);
|
||||
setPopup({
|
||||
message: `${isFolder ? "Folder" : "File"} renamed successfully`,
|
||||
type: "success",
|
||||
});
|
||||
await refreshFolderDetails();
|
||||
} catch (error) {
|
||||
console.error("Error renaming item:", error);
|
||||
setPopup({
|
||||
message: `Failed to rename ${isFolder ? "folder" : "file"}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
setEditingItemId(null);
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setEditingItemId(null);
|
||||
setNewItemName("");
|
||||
};
|
||||
|
||||
const handleDeleteItem = (
|
||||
itemId: number,
|
||||
isFolder: boolean,
|
||||
itemName: string
|
||||
) => {
|
||||
setDeleteItemId(itemId);
|
||||
setDeleteItemType(isFolder ? "folder" : "file");
|
||||
setDeleteItemName(itemName);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteItemId !== null) {
|
||||
try {
|
||||
await deleteItem(deleteItemId, deleteItemType === "folder");
|
||||
setPopup({
|
||||
message: `${deleteItemType} deleted successfully`,
|
||||
type: "success",
|
||||
});
|
||||
await refreshFolderDetails();
|
||||
} catch (error) {
|
||||
console.error("Error deleting item:", error);
|
||||
setPopup({
|
||||
message: `Failed to delete ${deleteItemType}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
const handleMoveFolder = () => {
|
||||
setIsMoveModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmMove = async (targetFolderId: number) => {
|
||||
try {
|
||||
await moveItem(folderId, targetFolderId, true);
|
||||
setPopup({
|
||||
message: "Folder moved successfully",
|
||||
type: "success",
|
||||
});
|
||||
router.push(`/chat/my-documents/${targetFolderId}`);
|
||||
} catch (error) {
|
||||
console.error("Error moving folder:", error);
|
||||
setPopup({
|
||||
message: "Failed to move folder",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
setIsMoveModalOpen(false);
|
||||
};
|
||||
|
||||
const handleMoveFile = async (fileId: number, targetFolderId: number) => {
|
||||
try {
|
||||
await moveItem(fileId, targetFolderId, false);
|
||||
setPopup({
|
||||
message: "File moved successfully",
|
||||
type: "success",
|
||||
});
|
||||
await refreshFolderDetails();
|
||||
} catch (error) {
|
||||
console.error("Error moving file:", error);
|
||||
setPopup({
|
||||
message: "Failed to move file",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full w-full min-w-0 flex-1 mx-auto max-w-5xl px-4 pb-20 md:pl-8 mt-6 md:pr-8 2xl:pr-14">
|
||||
{popup}
|
||||
{folderCreatedPopup}
|
||||
<DeleteEntityModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
entityType={deleteItemType}
|
||||
entityName={deleteItemName}
|
||||
/>
|
||||
<MoveFolderModal
|
||||
isOpen={isMoveModalOpen}
|
||||
onClose={() => setIsMoveModalOpen(false)}
|
||||
onMove={confirmMove}
|
||||
folders={folders}
|
||||
currentFolderId={folderId}
|
||||
/>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex-1 mr-4">
|
||||
<div
|
||||
className="flex text-sm mb-4 items-center cursor-pointer text-neutral-700 dark:text-neutral-300"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Back to My Knowledge Groups
|
||||
</div>
|
||||
{editingItemId === folderDetails.id ? (
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={newItemName}
|
||||
onChange={(e) => setNewItemName(e.target.value)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleSaveRename(folderDetails.id, true)}
|
||||
className="mr-2"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={handleCancelRename} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<h1
|
||||
className="flex items-center gap-1.5 text-lg font-medium leading-tight tracking-tight max-md:hidden cursor-pointer mr-4 text-neutral-900 dark:text-neutral-100"
|
||||
onClick={() =>
|
||||
handleRenameItem(folderDetails.id, folderDetails.name, true)
|
||||
}
|
||||
>
|
||||
{folderDetails.name}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-neutral-600 dark:text-neutral-200 mb-4">
|
||||
{folderDetails.description}
|
||||
</p>
|
||||
|
||||
<DocumentList
|
||||
isLoading={false}
|
||||
files={folderDetails.files}
|
||||
onRename={handleRenameItem}
|
||||
onDelete={handleDeleteItem}
|
||||
onDownload={downloadItem}
|
||||
onUpload={handleUpload}
|
||||
onMove={handleMoveFile}
|
||||
folders={folders}
|
||||
disabled={folderDetails.id === -1}
|
||||
editingItemId={editingItemId}
|
||||
onSaveRename={handleSaveRename}
|
||||
onCancelRename={handleCancelRename}
|
||||
newItemName={newItemName}
|
||||
setNewItemName={setNewItemName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[313.33px] bg-[#fff] dark:bg-neutral-800 mt-20 relative rounded-md border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<ContextLimitPanel
|
||||
isOpen={isCapacityOpen}
|
||||
onToggle={() => setIsCapacityOpen(!isCapacityOpen)}
|
||||
tokenPercentage={tokenPercentage}
|
||||
totalTokens={totalTokens}
|
||||
maxTokens={maxTokens}
|
||||
selectedModel={selectedModel}
|
||||
modelDescriptors={modelDescriptors}
|
||||
onSelectModel={setSelectedModel}
|
||||
/>
|
||||
|
||||
<SharingPanel
|
||||
assistantIds={folderDetails.assistant_ids}
|
||||
assistants={assistants}
|
||||
isOpen={isSharedOpen}
|
||||
onToggle={() => setIsSharedOpen(!isSharedOpen)}
|
||||
/>
|
||||
|
||||
<AddWebsitePanel
|
||||
folderId={folderId}
|
||||
onCreateFileFromLink={handleCreateFileFromLink}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={handleStartChat}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Chat with This Group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { FileResponse, FolderResponse } from "../../DocumentsContext";
|
||||
import {
|
||||
FileListItem,
|
||||
SkeletonFileListItem,
|
||||
} from "../../components/FileListItem";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Grid, List, Loader2 } from "lucide-react";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import TextView from "@/components/chat/TextView";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FileUploadSection } from "./upload/FileUploadSection";
|
||||
|
||||
interface DocumentListProps {
|
||||
files: FileResponse[];
|
||||
onRename: (
|
||||
itemId: number,
|
||||
currentName: string,
|
||||
isFolder: boolean
|
||||
) => Promise<void>;
|
||||
onDelete: (itemId: number, isFolder: boolean, itemName: string) => void;
|
||||
onDownload: (documentId: string) => Promise<void>;
|
||||
onUpload: (files: File[]) => void;
|
||||
onMove: (fileId: number, targetFolderId: number) => Promise<void>;
|
||||
folders: FolderResponse[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
editingItemId: number | null;
|
||||
onSaveRename: (itemId: number, isFolder: boolean) => Promise<void>;
|
||||
onCancelRename: () => void;
|
||||
newItemName: string;
|
||||
setNewItemName: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
files,
|
||||
onRename,
|
||||
onDelete,
|
||||
onDownload,
|
||||
onUpload,
|
||||
onMove,
|
||||
folders,
|
||||
isLoading,
|
||||
disabled,
|
||||
editingItemId,
|
||||
onSaveRename,
|
||||
onCancelRename,
|
||||
newItemName,
|
||||
setNewItemName,
|
||||
}) => {
|
||||
const [presentingDocument, setPresentingDocument] =
|
||||
useState<MinimalOnyxDocument | null>(null);
|
||||
const [view, setView] = useState<"grid" | "list">("list");
|
||||
|
||||
const toggleView = () => {
|
||||
setView(view === "grid" ? "list" : "grid");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{presentingDocument && (
|
||||
<TextView
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
Documents in this Project
|
||||
</h2>
|
||||
<Button onClick={toggleView} variant="outline" size="sm">
|
||||
{view === "grid" ? <List size={16} /> : <Grid size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
<FileUploadSection
|
||||
disabled={disabled}
|
||||
disabledMessage={
|
||||
disabled
|
||||
? "This folder cannot be edited. It contains your recent documents."
|
||||
: undefined
|
||||
}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
|
||||
<div className={view === "grid" ? "grid grid-cols-4 gap-4" : "space-y-2"}>
|
||||
{files.map((file) => (
|
||||
<div key={file.id}>
|
||||
{editingItemId === file.id ? (
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={newItemName}
|
||||
onChange={(e) => setNewItemName(e.target.value)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onSaveRename(file.id, false)}
|
||||
className="mr-2"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancelRename} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileListItem
|
||||
file={file}
|
||||
view={view}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
onMove={onMove}
|
||||
folders={folders}
|
||||
onSelect={() =>
|
||||
setPresentingDocument({
|
||||
semantic_identifier: file.name,
|
||||
document_id: file.document_id,
|
||||
})
|
||||
}
|
||||
isIndexed={file.indexed || false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isLoading && <SkeletonFileListItem view={view} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AddWebsitePanelProps {
|
||||
folderId: number;
|
||||
onCreateFileFromLink: (url: string, folderId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function AddWebsitePanel({
|
||||
folderId,
|
||||
onCreateFileFromLink,
|
||||
}: AddWebsitePanelProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleCreateFileFromLink = async () => {
|
||||
if (!linkUrl) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreateFileFromLink(linkUrl, folderId);
|
||||
setLinkUrl("");
|
||||
} catch (error) {
|
||||
console.error("Error creating file from link:", error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div
|
||||
className="flex items-center justify-between w-full cursor-pointer text-[#13343a] dark:text-neutral-300"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Link className="w-5 h-4 mr-3" />
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
Add a website
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6 p-0">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="flex mt-4 items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="Enter URL"
|
||||
className="flex-grow !text-sm mr-2 px-2 py-1 border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
className="!text-sm"
|
||||
size="xs"
|
||||
onClick={handleCreateFileFromLink}
|
||||
disabled={isCreating || !linkUrl}
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user