mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-25 19:55:47 +00:00
Compare commits
34 Commits
v0.3.96
...
eval/split
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4293543a6a | ||
|
|
e95bfa0e0b | ||
|
|
4848b5f1de | ||
|
|
7ba5c434fa | ||
|
|
59bf5ba848 | ||
|
|
f66c33380c | ||
|
|
115650ce9f | ||
|
|
7aa3602fca | ||
|
|
864c552a17 | ||
|
|
07b2ed3d8f | ||
|
|
38290057f2 | ||
|
|
2344edf158 | ||
|
|
86d1804eb0 | ||
|
|
1ebae50d0c | ||
|
|
a9fbaa396c | ||
|
|
27d5f69427 | ||
|
|
5d98421ae8 | ||
|
|
6b561b8ca9 | ||
|
|
2dc7e64dd7 | ||
|
|
5230f7e22f | ||
|
|
a595d43ae3 | ||
|
|
ee561f42ff | ||
|
|
f00b3d76b3 | ||
|
|
e4984153c0 | ||
|
|
87fadb07ea | ||
|
|
2b07c102f9 | ||
|
|
e93de602c3 | ||
|
|
1c77395503 | ||
|
|
cdf6089b3e | ||
|
|
d01f46af2b | ||
|
|
b83f435bb0 | ||
|
|
25b3dacaba | ||
|
|
a1e638a73d | ||
|
|
bd1e0c5969 |
@@ -1,8 +1,6 @@
|
||||
name: Build Backend Image on Merge Group
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
name: Build Web Image on Merge Group
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add search doc relevance details
|
||||
|
||||
Revision ID: 05c07bf07c00
|
||||
Revises: b896bbd0d5a7
|
||||
Create Date: 2024-07-10 17:48:15.886653
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "05c07bf07c00"
|
||||
down_revision = "b896bbd0d5a7"
|
||||
branch_labels: None = None
|
||||
depends_on: None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"search_doc",
|
||||
sa.Column("is_relevant", sa.Boolean(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"search_doc",
|
||||
sa.Column("relevance_explanation", sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("search_doc", "relevance_explanation")
|
||||
op.drop_column("search_doc", "is_relevant")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""backfill is_internet data to False
|
||||
|
||||
Revision ID: b896bbd0d5a7
|
||||
Revises: 44f856ae2a4a
|
||||
Create Date: 2024-07-16 15:21:05.718571
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b896bbd0d5a7"
|
||||
down_revision = "44f856ae2a4a"
|
||||
branch_labels: None = None
|
||||
depends_on: None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("UPDATE search_doc SET is_internet = FALSE WHERE is_internet IS NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -42,11 +42,21 @@ class QADocsResponse(RetrievalDocs):
|
||||
return initial_dict
|
||||
|
||||
|
||||
# Second chunk of info for streaming QA
|
||||
class LLMRelevanceFilterResponse(BaseModel):
|
||||
relevant_chunk_indices: list[int]
|
||||
|
||||
|
||||
class RelevanceChunk(BaseModel):
|
||||
# TODO make this document level. Also slight misnomer here as this is actually
|
||||
# done at the section level currently rather than the chunk
|
||||
relevant: bool | None = None
|
||||
content: str | None = None
|
||||
|
||||
|
||||
class LLMRelevanceSummaryResponse(BaseModel):
|
||||
relevance_summaries: dict[str, RelevanceChunk]
|
||||
|
||||
|
||||
class DanswerAnswerPiece(BaseModel):
|
||||
# A small piece of a complete answer. Used for streaming back answers.
|
||||
answer_piece: str | None # if None, specifies the end of an Answer
|
||||
|
||||
@@ -223,6 +223,11 @@ MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE = int(
|
||||
os.environ.get("MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE", 0)
|
||||
)
|
||||
|
||||
# comma delimited list of zendesk article labels to skip indexing for
|
||||
ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS = os.environ.get(
|
||||
"ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS", ""
|
||||
).split(",")
|
||||
|
||||
|
||||
#####
|
||||
# Indexing Configs
|
||||
|
||||
@@ -75,6 +75,17 @@ LANGUAGE_CHAT_NAMING_HINT = (
|
||||
or "The name of the conversation must be in the same language as the user query."
|
||||
)
|
||||
|
||||
|
||||
# Agentic search takes significantly more tokens and therefore has much higher cost.
|
||||
# This configuration allows users to get a search-only experience with instant results
|
||||
# and no involvement from the LLM.
|
||||
# Additionally, some LLM providers have strict rate limits which may prohibit
|
||||
# sending many API requests at once (as is done in agentic search).
|
||||
DISABLE_AGENTIC_SEARCH = (
|
||||
os.environ.get("DISABLE_AGENTIC_SEARCH") or "false"
|
||||
).lower() == "true"
|
||||
|
||||
|
||||
# Stops streaming answers back to the UI if this pattern is seen:
|
||||
STOP_STREAM_PAT = os.environ.get("STOP_STREAM_PAT") or None
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def make_confluence_call_handle_rate_limit(confluence_call: F) -> F:
|
||||
retry_after = None
|
||||
try:
|
||||
retry_after = int(e.response.headers.get("Retry-After"))
|
||||
except ValueError:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if retry_after:
|
||||
|
||||
@@ -11,6 +11,9 @@ from requests import Response
|
||||
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
|
||||
from danswer.connectors.cross_connector_utils.rate_limit_wrapper import (
|
||||
rate_limit_builder,
|
||||
)
|
||||
from danswer.connectors.cross_connector_utils.retry_wrapper import retry_builder
|
||||
from danswer.connectors.interfaces import GenerateDocumentsOutput
|
||||
from danswer.connectors.interfaces import PollConnector
|
||||
@@ -58,67 +61,36 @@ class DiscourseConnector(PollConnector):
|
||||
self.category_id_map: dict[int, str] = {}
|
||||
|
||||
self.batch_size = batch_size
|
||||
|
||||
self.permissions: DiscoursePerms | None = None
|
||||
self.active_categories: set | None = None
|
||||
|
||||
@rate_limit_builder(max_calls=100, period=60)
|
||||
def _make_request(self, endpoint: str, params: dict | None = None) -> Response:
|
||||
if not self.permissions:
|
||||
raise ConnectorMissingCredentialError("Discourse")
|
||||
return discourse_request(endpoint, self.permissions, params)
|
||||
|
||||
def _get_categories_map(
|
||||
self,
|
||||
) -> None:
|
||||
assert self.permissions is not None
|
||||
categories_endpoint = urllib.parse.urljoin(self.base_url, "categories.json")
|
||||
response = discourse_request(
|
||||
response = self._make_request(
|
||||
endpoint=categories_endpoint,
|
||||
perms=self.permissions,
|
||||
params={"include_subcategories": True},
|
||||
)
|
||||
categories = response.json()["category_list"]["categories"]
|
||||
|
||||
self.category_id_map = {
|
||||
category["id"]: category["name"]
|
||||
for category in categories
|
||||
if not self.categories or category["name"].lower() in self.categories
|
||||
cat["id"]: cat["name"]
|
||||
for cat in categories
|
||||
if not self.categories or cat["name"].lower() in self.categories
|
||||
}
|
||||
|
||||
def _get_latest_topics(
|
||||
self, start: datetime | None, end: datetime | None
|
||||
) -> list[int]:
|
||||
assert self.permissions is not None
|
||||
topic_ids = []
|
||||
|
||||
valid_categories = set(self.category_id_map.keys())
|
||||
|
||||
latest_endpoint = urllib.parse.urljoin(self.base_url, "latest.json")
|
||||
response = discourse_request(endpoint=latest_endpoint, perms=self.permissions)
|
||||
topics = response.json()["topic_list"]["topics"]
|
||||
for topic in topics:
|
||||
last_time = topic.get("last_posted_at")
|
||||
if not last_time:
|
||||
continue
|
||||
last_time_dt = time_str_to_utc(last_time)
|
||||
|
||||
if start and start > last_time_dt:
|
||||
continue
|
||||
if end and end < last_time_dt:
|
||||
continue
|
||||
|
||||
if (
|
||||
self.categories
|
||||
and valid_categories
|
||||
and topic.get("category_id") not in valid_categories
|
||||
):
|
||||
continue
|
||||
|
||||
topic_ids.append(topic["id"])
|
||||
|
||||
return topic_ids
|
||||
self.active_categories = set(self.category_id_map)
|
||||
|
||||
def _get_doc_from_topic(self, topic_id: int) -> Document:
|
||||
assert self.permissions is not None
|
||||
topic_endpoint = urllib.parse.urljoin(self.base_url, f"t/{topic_id}.json")
|
||||
response = discourse_request(
|
||||
endpoint=topic_endpoint,
|
||||
perms=self.permissions,
|
||||
)
|
||||
response = self._make_request(endpoint=topic_endpoint)
|
||||
topic = response.json()
|
||||
|
||||
topic_url = urllib.parse.urljoin(self.base_url, f"t/{topic['slug']}")
|
||||
@@ -167,26 +139,78 @@ class DiscourseConnector(PollConnector):
|
||||
)
|
||||
return doc
|
||||
|
||||
def _get_latest_topics(
|
||||
self, start: datetime | None, end: datetime | None, page: int
|
||||
) -> list[int]:
|
||||
assert self.permissions is not None
|
||||
topic_ids = []
|
||||
|
||||
if not self.categories:
|
||||
latest_endpoint = urllib.parse.urljoin(
|
||||
self.base_url, f"latest.json?page={page}"
|
||||
)
|
||||
response = self._make_request(endpoint=latest_endpoint)
|
||||
topics = response.json()["topic_list"]["topics"]
|
||||
|
||||
else:
|
||||
topics = []
|
||||
empty_categories = []
|
||||
|
||||
for category_id in self.category_id_map.keys():
|
||||
category_endpoint = urllib.parse.urljoin(
|
||||
self.base_url, f"c/{category_id}.json?page={page}&sys=latest"
|
||||
)
|
||||
response = self._make_request(endpoint=category_endpoint)
|
||||
new_topics = response.json()["topic_list"]["topics"]
|
||||
|
||||
if len(new_topics) == 0:
|
||||
empty_categories.append(category_id)
|
||||
topics.extend(new_topics)
|
||||
|
||||
for empty_category in empty_categories:
|
||||
self.category_id_map.pop(empty_category)
|
||||
|
||||
for topic in topics:
|
||||
last_time = topic.get("last_posted_at")
|
||||
if not last_time:
|
||||
continue
|
||||
|
||||
last_time_dt = time_str_to_utc(last_time)
|
||||
if (start and start > last_time_dt) or (end and end < last_time_dt):
|
||||
continue
|
||||
|
||||
topic_ids.append(topic["id"])
|
||||
if len(topic_ids) >= self.batch_size:
|
||||
break
|
||||
|
||||
return topic_ids
|
||||
|
||||
def _yield_discourse_documents(
|
||||
self, topic_ids: list[int]
|
||||
self,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> GenerateDocumentsOutput:
|
||||
doc_batch: list[Document] = []
|
||||
for topic_id in topic_ids:
|
||||
doc_batch.append(self._get_doc_from_topic(topic_id))
|
||||
page = 1
|
||||
while topic_ids := self._get_latest_topics(start, end, page):
|
||||
doc_batch: list[Document] = []
|
||||
for topic_id in topic_ids:
|
||||
doc_batch.append(self._get_doc_from_topic(topic_id))
|
||||
if len(doc_batch) >= self.batch_size:
|
||||
yield doc_batch
|
||||
doc_batch = []
|
||||
|
||||
if len(doc_batch) >= self.batch_size:
|
||||
if doc_batch:
|
||||
yield doc_batch
|
||||
doc_batch = []
|
||||
page += 1
|
||||
|
||||
if doc_batch:
|
||||
yield doc_batch
|
||||
|
||||
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
||||
def load_credentials(
|
||||
self,
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
self.permissions = DiscoursePerms(
|
||||
api_key=credentials["discourse_api_key"],
|
||||
api_username=credentials["discourse_api_username"],
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def poll_source(
|
||||
@@ -194,16 +218,13 @@ class DiscourseConnector(PollConnector):
|
||||
) -> GenerateDocumentsOutput:
|
||||
if self.permissions is None:
|
||||
raise ConnectorMissingCredentialError("Discourse")
|
||||
|
||||
start_datetime = datetime.utcfromtimestamp(start).replace(tzinfo=timezone.utc)
|
||||
end_datetime = datetime.utcfromtimestamp(end).replace(tzinfo=timezone.utc)
|
||||
|
||||
self._get_categories_map()
|
||||
|
||||
latest_topic_ids = self._get_latest_topics(
|
||||
start=start_datetime, end=end_datetime
|
||||
)
|
||||
|
||||
yield from self._yield_discourse_documents(latest_topic_ids)
|
||||
yield from self._yield_discourse_documents(start_datetime, end_datetime)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -219,7 +240,5 @@ if __name__ == "__main__":
|
||||
|
||||
current = time.time()
|
||||
one_year_ago = current - 24 * 60 * 60 * 360
|
||||
|
||||
latest_docs = connector.poll_source(one_year_ago, current)
|
||||
|
||||
print(next(latest_docs))
|
||||
|
||||
@@ -88,7 +88,7 @@ def _process_file(
|
||||
# add a prefix to avoid conflicts with other connectors
|
||||
doc_id = f"FILE_CONNECTOR__{file_name}"
|
||||
if metadata:
|
||||
doc_id = metadata.get("id") or doc_id
|
||||
doc_id = metadata.get("document_id") or doc_id
|
||||
|
||||
# If this is set, we will show this in the UI as the "name" of the file
|
||||
file_display_name = all_metadata.get("file_display_name") or os.path.basename(
|
||||
@@ -111,6 +111,7 @@ def _process_file(
|
||||
for k, v in all_metadata.items()
|
||||
if k
|
||||
not in [
|
||||
"document_id",
|
||||
"time_updated",
|
||||
"doc_updated_at",
|
||||
"link",
|
||||
|
||||
@@ -4,6 +4,7 @@ from zenpy import Zenpy # type: ignore
|
||||
from zenpy.lib.api_objects.help_centre_objects import Article # type: ignore
|
||||
|
||||
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from danswer.configs.app_configs import ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.connectors.cross_connector_utils.miscellaneous_utils import (
|
||||
time_str_to_utc,
|
||||
@@ -81,7 +82,14 @@ class ZendeskConnector(LoadConnector, PollConnector):
|
||||
)
|
||||
doc_batch = []
|
||||
for article in articles:
|
||||
if article.body is None or article.draft:
|
||||
if (
|
||||
article.body is None
|
||||
or article.draft
|
||||
or any(
|
||||
label in ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS
|
||||
for label in article.label_names
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
doc_batch.append(_article_to_document(article))
|
||||
|
||||
@@ -3,15 +3,20 @@ from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import nullsfirst
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.exc import MultipleResultsFound
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.schemas import UserRole
|
||||
from danswer.chat.models import LLMRelevanceSummaryResponse
|
||||
from danswer.configs.chat_configs import HARD_DELETE_CHATS
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.db.models import ChatMessage
|
||||
@@ -34,6 +39,7 @@ from danswer.server.query_and_chat.models import ChatMessageDetail
|
||||
from danswer.tools.tool_runner import ToolCallFinalResult
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
@@ -81,17 +87,46 @@ def get_chat_sessions_by_slack_thread_id(
|
||||
return db_session.scalars(stmt).all()
|
||||
|
||||
|
||||
def get_first_messages_for_chat_sessions(
|
||||
chat_session_ids: list[int], db_session: Session
|
||||
) -> dict[int, str]:
|
||||
subquery = (
|
||||
select(ChatMessage.chat_session_id, func.min(ChatMessage.id).label("min_id"))
|
||||
.where(
|
||||
and_(
|
||||
ChatMessage.chat_session_id.in_(chat_session_ids),
|
||||
ChatMessage.message_type == MessageType.USER, # Select USER messages
|
||||
)
|
||||
)
|
||||
.group_by(ChatMessage.chat_session_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = select(ChatMessage.chat_session_id, ChatMessage.message).join(
|
||||
subquery,
|
||||
(ChatMessage.chat_session_id == subquery.c.chat_session_id)
|
||||
& (ChatMessage.id == subquery.c.min_id),
|
||||
)
|
||||
|
||||
first_messages = db_session.execute(query).all()
|
||||
return dict([(row.chat_session_id, row.message) for row in first_messages])
|
||||
|
||||
|
||||
def get_chat_sessions_by_user(
|
||||
user_id: UUID | None,
|
||||
deleted: bool | None,
|
||||
db_session: Session,
|
||||
include_one_shot: bool = False,
|
||||
only_one_shot: bool = False,
|
||||
) -> list[ChatSession]:
|
||||
stmt = select(ChatSession).where(ChatSession.user_id == user_id)
|
||||
|
||||
if not include_one_shot:
|
||||
if only_one_shot:
|
||||
stmt = stmt.where(ChatSession.one_shot.is_(True))
|
||||
else:
|
||||
stmt = stmt.where(ChatSession.one_shot.is_(False))
|
||||
|
||||
stmt = stmt.order_by(desc(ChatSession.time_created))
|
||||
|
||||
if deleted is not None:
|
||||
stmt = stmt.where(ChatSession.deleted == deleted)
|
||||
|
||||
@@ -111,6 +146,12 @@ def delete_search_doc_message_relationship(
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def delete_tool_call_for_message_id(message_id: int, db_session: Session) -> None:
|
||||
stmt = delete(ToolCall).where(ToolCall.message_id == message_id)
|
||||
db_session.execute(stmt)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def delete_orphaned_search_docs(db_session: Session) -> None:
|
||||
orphaned_docs = (
|
||||
db_session.query(SearchDoc)
|
||||
@@ -134,6 +175,7 @@ def delete_messages_and_files_from_chat_session(
|
||||
).fetchall()
|
||||
|
||||
for id, files in messages_with_files:
|
||||
delete_tool_call_for_message_id(message_id=id, db_session=db_session)
|
||||
delete_search_doc_message_relationship(message_id=id, db_session=db_session)
|
||||
for file_info in files or {}:
|
||||
lobj_name = file_info.get("id")
|
||||
@@ -275,6 +317,20 @@ def get_chat_messages_by_sessions(
|
||||
return db_session.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def get_search_docs_for_chat_message(
|
||||
chat_message_id: int, db_session: Session
|
||||
) -> list[SearchDoc]:
|
||||
stmt = (
|
||||
select(SearchDoc)
|
||||
.join(
|
||||
ChatMessage__SearchDoc, ChatMessage__SearchDoc.search_doc_id == SearchDoc.id
|
||||
)
|
||||
.where(ChatMessage__SearchDoc.chat_message_id == chat_message_id)
|
||||
)
|
||||
|
||||
return list(db_session.scalars(stmt).all())
|
||||
|
||||
|
||||
def get_chat_messages_by_session(
|
||||
chat_session_id: int,
|
||||
user_id: UUID | None,
|
||||
@@ -295,8 +351,6 @@ def get_chat_messages_by_session(
|
||||
|
||||
if prefetch_tool_calls:
|
||||
stmt = stmt.options(joinedload(ChatMessage.tool_calls))
|
||||
|
||||
if prefetch_tool_calls:
|
||||
result = db_session.scalars(stmt).unique().all()
|
||||
else:
|
||||
result = db_session.scalars(stmt).all()
|
||||
@@ -484,6 +538,27 @@ def get_doc_query_identifiers_from_model(
|
||||
return doc_query_identifiers
|
||||
|
||||
|
||||
def update_search_docs_table_with_relevance(
|
||||
db_session: Session,
|
||||
reference_db_search_docs: list[SearchDoc],
|
||||
relevance_summary: LLMRelevanceSummaryResponse,
|
||||
) -> None:
|
||||
for search_doc in reference_db_search_docs:
|
||||
relevance_data = relevance_summary.relevance_summaries.get(
|
||||
f"{search_doc.document_id}-{search_doc.chunk_ind}"
|
||||
)
|
||||
if relevance_data is not None:
|
||||
db_session.execute(
|
||||
update(SearchDoc)
|
||||
.where(SearchDoc.id == search_doc.id)
|
||||
.values(
|
||||
is_relevant=relevance_data.relevant,
|
||||
relevance_explanation=relevance_data.content,
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def create_db_search_doc(
|
||||
server_search_doc: ServerSearchDoc,
|
||||
db_session: Session,
|
||||
@@ -498,6 +573,8 @@ def create_db_search_doc(
|
||||
boost=server_search_doc.boost,
|
||||
hidden=server_search_doc.hidden,
|
||||
doc_metadata=server_search_doc.metadata,
|
||||
is_relevant=server_search_doc.is_relevant,
|
||||
relevance_explanation=server_search_doc.relevance_explanation,
|
||||
# For docs further down that aren't reranked, we can't use the retrieval score
|
||||
score=server_search_doc.score or 0.0,
|
||||
match_highlights=server_search_doc.match_highlights,
|
||||
@@ -509,7 +586,6 @@ def create_db_search_doc(
|
||||
|
||||
db_session.add(db_search_doc)
|
||||
db_session.commit()
|
||||
|
||||
return db_search_doc
|
||||
|
||||
|
||||
@@ -538,6 +614,8 @@ def translate_db_search_doc_to_server_search_doc(
|
||||
match_highlights=(
|
||||
db_search_doc.match_highlights if not remove_doc_content else []
|
||||
),
|
||||
relevance_explanation=db_search_doc.relevance_explanation,
|
||||
is_relevant=db_search_doc.is_relevant,
|
||||
updated_at=db_search_doc.updated_at if not remove_doc_content else None,
|
||||
primary_owners=db_search_doc.primary_owners if not remove_doc_content else [],
|
||||
secondary_owners=(
|
||||
@@ -561,9 +639,11 @@ def get_retrieval_docs_from_chat_message(
|
||||
|
||||
|
||||
def translate_db_message_to_chat_message_detail(
|
||||
chat_message: ChatMessage, remove_doc_content: bool = False
|
||||
chat_message: ChatMessage,
|
||||
remove_doc_content: bool = False,
|
||||
) -> ChatMessageDetail:
|
||||
chat_msg_detail = ChatMessageDetail(
|
||||
chat_session_id=chat_message.chat_session_id,
|
||||
message_id=chat_message.id,
|
||||
parent_message=chat_message.parent_message,
|
||||
latest_child_message=chat_message.latest_child_message,
|
||||
|
||||
@@ -671,6 +671,9 @@ class SearchDoc(Base):
|
||||
)
|
||||
is_internet: Mapped[bool] = mapped_column(Boolean, default=False, nullable=True)
|
||||
|
||||
is_relevant: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
relevance_explanation: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
chat_messages = relationship(
|
||||
"ChatMessage",
|
||||
secondary="chat_message__search_doc",
|
||||
|
||||
@@ -153,43 +153,41 @@ schema DANSWER_CHUNK_NAME {
|
||||
query(query_embedding) tensor<float>(x[VARIABLE_DIM])
|
||||
}
|
||||
|
||||
# This must be separate function for normalize_linear to work
|
||||
function vector_score() {
|
||||
function title_vector_score() {
|
||||
expression {
|
||||
# If no title, the full vector score comes from the content embedding
|
||||
(query(title_content_ratio) * if(attribute(skip_title), closeness(field, embeddings), closeness(field, title_embedding))) +
|
||||
((1 - query(title_content_ratio)) * closeness(field, embeddings))
|
||||
}
|
||||
}
|
||||
|
||||
# This must be separate function for normalize_linear to work
|
||||
function keyword_score() {
|
||||
expression {
|
||||
(query(title_content_ratio) * bm25(title)) +
|
||||
((1 - query(title_content_ratio)) * bm25(content))
|
||||
#query(title_content_ratio) * if(attribute(skip_title), closeness(field, embeddings), closeness(field, title_embedding))
|
||||
if(attribute(skip_title), closeness(field, embeddings), closeness(field, title_embedding))
|
||||
}
|
||||
}
|
||||
|
||||
first-phase {
|
||||
expression: vector_score
|
||||
expression: closeness(field, embeddings)
|
||||
}
|
||||
|
||||
# Weighted average between Vector Search and BM-25
|
||||
# Each is a weighted average between the Title and Content fields
|
||||
# Finally each doc is boosted by it's user feedback based boost and recency
|
||||
# If any embedding or index field is missing, it just receives a score of 0
|
||||
# Assumptions:
|
||||
# - For a given query + corpus, the BM-25 scores will be relatively similar in distribution
|
||||
# therefore not normalizing before combining.
|
||||
# - For documents without title, it gets a score of 0 for that and this is ok as documents
|
||||
# without any title match should be penalized.
|
||||
global-phase {
|
||||
expression {
|
||||
(
|
||||
# Weighted Vector Similarity Score
|
||||
(query(alpha) * normalize_linear(vector_score)) +
|
||||
(
|
||||
query(alpha) * (
|
||||
(query(title_content_ratio) * normalize_linear(title_vector_score))
|
||||
+
|
||||
((1 - query(title_content_ratio)) * normalize_linear(closeness(field, embeddings)))
|
||||
)
|
||||
)
|
||||
|
||||
+
|
||||
|
||||
# Weighted Keyword Similarity Score
|
||||
((1 - query(alpha)) * normalize_linear(keyword_score))
|
||||
(
|
||||
(1 - query(alpha)) * (
|
||||
(query(title_content_ratio) * normalize_linear(bm25(title)))
|
||||
+
|
||||
((1 - query(title_content_ratio)) * normalize_linear(bm25(content)))
|
||||
)
|
||||
)
|
||||
)
|
||||
# Boost based on user feedback
|
||||
* document_boost
|
||||
@@ -204,8 +202,6 @@ schema DANSWER_CHUNK_NAME {
|
||||
bm25(content)
|
||||
closeness(field, title_embedding)
|
||||
closeness(field, embeddings)
|
||||
keyword_score
|
||||
vector_score
|
||||
document_boost
|
||||
recency_bias
|
||||
closest(embeddings)
|
||||
|
||||
@@ -89,6 +89,9 @@ def _get_answer_stream_processor(
|
||||
AnswerStream = Iterator[AnswerQuestionPossibleReturn | ToolCallKickoff | ToolResponse]
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
class Answer:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -112,6 +115,7 @@ class Answer:
|
||||
skip_explicit_tool_calling: bool = False,
|
||||
# Returns the full document sections text from the search tool
|
||||
return_contexts: bool = False,
|
||||
skip_gen_ai_answer_generation: bool = False,
|
||||
) -> None:
|
||||
if single_message_history and message_history:
|
||||
raise ValueError(
|
||||
@@ -140,11 +144,12 @@ class Answer:
|
||||
self._final_prompt: list[BaseMessage] | None = None
|
||||
|
||||
self._streamed_output: list[str] | None = None
|
||||
self._processed_stream: list[
|
||||
AnswerQuestionPossibleReturn | ToolResponse | ToolCallKickoff
|
||||
] | None = None
|
||||
self._processed_stream: (
|
||||
list[AnswerQuestionPossibleReturn | ToolResponse | ToolCallKickoff] | None
|
||||
) = None
|
||||
|
||||
self._return_contexts = return_contexts
|
||||
self.skip_gen_ai_answer_generation = skip_gen_ai_answer_generation
|
||||
|
||||
def _update_prompt_builder_for_search_tool(
|
||||
self, prompt_builder: AnswerPromptBuilder, final_context_documents: list[LlmDoc]
|
||||
@@ -403,8 +408,9 @@ class Answer:
|
||||
)
|
||||
)
|
||||
)
|
||||
final = tool_runner.tool_final_result()
|
||||
|
||||
yield tool_runner.tool_final_result()
|
||||
yield final
|
||||
|
||||
prompt = prompt_builder.build()
|
||||
yield from message_generator_to_string_generator(self.llm.stream(prompt=prompt))
|
||||
@@ -467,22 +473,23 @@ class Answer:
|
||||
# assumes all tool responses will come first, then the final answer
|
||||
break
|
||||
|
||||
process_answer_stream_fn = _get_answer_stream_processor(
|
||||
context_docs=final_context_docs or [],
|
||||
# if doc selection is enabled, then search_results will be None,
|
||||
# so we need to use the final_context_docs
|
||||
doc_id_to_rank_map=map_document_id_order(
|
||||
search_results or final_context_docs or []
|
||||
),
|
||||
answer_style_configs=self.answer_style_config,
|
||||
)
|
||||
if not self.skip_gen_ai_answer_generation:
|
||||
process_answer_stream_fn = _get_answer_stream_processor(
|
||||
context_docs=final_context_docs or [],
|
||||
# if doc selection is enabled, then search_results will be None,
|
||||
# so we need to use the final_context_docs
|
||||
doc_id_to_rank_map=map_document_id_order(
|
||||
search_results or final_context_docs or []
|
||||
),
|
||||
answer_style_configs=self.answer_style_config,
|
||||
)
|
||||
|
||||
def _stream() -> Iterator[str]:
|
||||
if message:
|
||||
yield cast(str, message)
|
||||
yield from cast(Iterator[str], stream)
|
||||
def _stream() -> Iterator[str]:
|
||||
if message:
|
||||
yield cast(str, message)
|
||||
yield from cast(Iterator[str], stream)
|
||||
|
||||
yield from process_answer_stream_fn(_stream())
|
||||
yield from process_answer_stream_fn(_stream())
|
||||
|
||||
processed_stream = []
|
||||
for processed_packet in _process_stream(output_generator):
|
||||
|
||||
@@ -265,6 +265,7 @@ def prune_sections(
|
||||
max_tokens=document_pruning_config.max_tokens,
|
||||
tool_token_count=document_pruning_config.tool_num_tokens,
|
||||
)
|
||||
|
||||
return _apply_pruning(
|
||||
sections=sections,
|
||||
section_relevance_list=section_relevance_list,
|
||||
|
||||
@@ -232,32 +232,6 @@ class DefaultMultiLLM(LLM):
|
||||
|
||||
self._model_kwargs = model_kwargs
|
||||
|
||||
@staticmethod
|
||||
def _log_prompt(prompt: LanguageModelInput) -> None:
|
||||
if isinstance(prompt, list):
|
||||
for ind, msg in enumerate(prompt):
|
||||
if isinstance(msg, AIMessageChunk):
|
||||
if msg.content:
|
||||
log_msg = msg.content
|
||||
elif msg.tool_call_chunks:
|
||||
log_msg = "Tool Calls: " + str(
|
||||
[
|
||||
{
|
||||
key: value
|
||||
for key, value in tool_call.items()
|
||||
if key != "index"
|
||||
}
|
||||
for tool_call in msg.tool_call_chunks
|
||||
]
|
||||
)
|
||||
else:
|
||||
log_msg = ""
|
||||
logger.debug(f"Message {ind}:\n{log_msg}")
|
||||
else:
|
||||
logger.debug(f"Message {ind}:\n{msg.content}")
|
||||
if isinstance(prompt, str):
|
||||
logger.debug(f"Prompt:\n{prompt}")
|
||||
|
||||
def log_model_configs(self) -> None:
|
||||
logger.info(f"Config: {self.config}")
|
||||
|
||||
@@ -311,7 +285,7 @@ class DefaultMultiLLM(LLM):
|
||||
api_version=self._api_version,
|
||||
)
|
||||
|
||||
def invoke(
|
||||
def _invoke_implementation(
|
||||
self,
|
||||
prompt: LanguageModelInput,
|
||||
tools: list[dict] | None = None,
|
||||
@@ -319,7 +293,6 @@ class DefaultMultiLLM(LLM):
|
||||
) -> BaseMessage:
|
||||
if LOG_DANSWER_MODEL_INTERACTIONS:
|
||||
self.log_model_configs()
|
||||
self._log_prompt(prompt)
|
||||
|
||||
response = cast(
|
||||
litellm.ModelResponse, self._completion(prompt, tools, tool_choice, False)
|
||||
@@ -328,7 +301,7 @@ class DefaultMultiLLM(LLM):
|
||||
response.choices[0].message
|
||||
)
|
||||
|
||||
def stream(
|
||||
def _stream_implementation(
|
||||
self,
|
||||
prompt: LanguageModelInput,
|
||||
tools: list[dict] | None = None,
|
||||
@@ -336,7 +309,6 @@ class DefaultMultiLLM(LLM):
|
||||
) -> Iterator[BaseMessage]:
|
||||
if LOG_DANSWER_MODEL_INTERACTIONS:
|
||||
self.log_model_configs()
|
||||
self._log_prompt(prompt)
|
||||
|
||||
if DISABLE_LITELLM_STREAMING:
|
||||
yield self.invoke(prompt)
|
||||
|
||||
@@ -76,7 +76,7 @@ class CustomModelServer(LLM):
|
||||
def log_model_configs(self) -> None:
|
||||
logger.debug(f"Custom model at: {self._endpoint}")
|
||||
|
||||
def invoke(
|
||||
def _invoke_implementation(
|
||||
self,
|
||||
prompt: LanguageModelInput,
|
||||
tools: list[dict] | None = None,
|
||||
@@ -84,7 +84,7 @@ class CustomModelServer(LLM):
|
||||
) -> BaseMessage:
|
||||
return self._execute(prompt)
|
||||
|
||||
def stream(
|
||||
def _stream_implementation(
|
||||
self,
|
||||
prompt: LanguageModelInput,
|
||||
tools: list[dict] | None = None,
|
||||
|
||||
@@ -3,9 +3,12 @@ from collections.abc import Iterator
|
||||
from typing import Literal
|
||||
|
||||
from langchain.schema.language_model import LanguageModelInput
|
||||
from langchain_core.messages import AIMessageChunk
|
||||
from langchain_core.messages import BaseMessage
|
||||
from pydantic import BaseModel
|
||||
|
||||
from danswer.configs.app_configs import DISABLE_GENERATIVE_AI
|
||||
from danswer.configs.app_configs import LOG_DANSWER_MODEL_INTERACTIONS
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
|
||||
@@ -23,6 +26,32 @@ class LLMConfig(BaseModel):
|
||||
api_version: str | None
|
||||
|
||||
|
||||
def log_prompt(prompt: LanguageModelInput) -> None:
|
||||
if isinstance(prompt, list):
|
||||
for ind, msg in enumerate(prompt):
|
||||
if isinstance(msg, AIMessageChunk):
|
||||
if msg.content:
|
||||
log_msg = msg.content
|
||||
elif msg.tool_call_chunks:
|
||||
log_msg = "Tool Calls: " + str(
|
||||
[
|
||||
{
|
||||
key: value
|
||||
for key, value in tool_call.items()
|
||||
if key != "index"
|
||||
}
|
||||
for tool_call in msg.tool_call_chunks
|
||||
]
|
||||
)
|
||||
else:
|
||||
log_msg = ""
|
||||
logger.debug(f"Message {ind}:\n{log_msg}")
|
||||
else:
|
||||
logger.debug(f"Message {ind}:\n{msg.content}")
|
||||
if isinstance(prompt, str):
|
||||
logger.debug(f"Prompt:\n{prompt}")
|
||||
|
||||
|
||||
class LLM(abc.ABC):
|
||||
"""Mimics the LangChain LLM / BaseChatModel interfaces to make it easy
|
||||
to use these implementations to connect to a variety of LLM providers."""
|
||||
@@ -45,20 +74,48 @@ class LLM(abc.ABC):
|
||||
def log_model_configs(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def _precall(self, prompt: LanguageModelInput) -> None:
|
||||
if DISABLE_GENERATIVE_AI:
|
||||
raise Exception("Generative AI is disabled")
|
||||
if LOG_DANSWER_MODEL_INTERACTIONS:
|
||||
log_prompt(prompt)
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
prompt: LanguageModelInput,
|
||||
tools: list[dict] | None = None,
|
||||
tool_choice: ToolChoiceOptions | None = None,
|
||||
) -> BaseMessage:
|
||||
self._precall(prompt)
|
||||
# TODO add a postcall to log model outputs independent of concrete class
|
||||
# implementation
|
||||
return self._invoke_implementation(prompt, tools, tool_choice)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _invoke_implementation(
|
||||
self,
|
||||
prompt: LanguageModelInput,
|
||||
tools: list[dict] | None = None,
|
||||
tool_choice: ToolChoiceOptions | None = None,
|
||||
) -> BaseMessage:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
prompt: LanguageModelInput,
|
||||
tools: list[dict] | None = None,
|
||||
tool_choice: ToolChoiceOptions | None = None,
|
||||
) -> Iterator[BaseMessage]:
|
||||
self._precall(prompt)
|
||||
# TODO add a postcall to log model outputs independent of concrete class
|
||||
# implementation
|
||||
return self._stream_implementation(prompt, tools, tool_choice)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _stream_implementation(
|
||||
self,
|
||||
prompt: LanguageModelInput,
|
||||
tools: list[dict] | None = None,
|
||||
tool_choice: ToolChoiceOptions | None = None,
|
||||
) -> Iterator[BaseMessage]:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -26,6 +26,7 @@ OPENAI_PROVIDER_NAME = "openai"
|
||||
OPEN_AI_MODEL_NAMES = [
|
||||
"gpt-4",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4-1106-preview",
|
||||
|
||||
@@ -10,6 +10,7 @@ from danswer.chat.models import DanswerAnswerPiece
|
||||
from danswer.chat.models import DanswerContexts
|
||||
from danswer.chat.models import DanswerQuotes
|
||||
from danswer.chat.models import LLMRelevanceFilterResponse
|
||||
from danswer.chat.models import LLMRelevanceSummaryResponse
|
||||
from danswer.chat.models import QADocsResponse
|
||||
from danswer.chat.models import StreamingError
|
||||
from danswer.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT
|
||||
@@ -21,6 +22,7 @@ from danswer.db.chat import create_new_chat_message
|
||||
from danswer.db.chat import get_or_create_root_message
|
||||
from danswer.db.chat import translate_db_message_to_chat_message_detail
|
||||
from danswer.db.chat import translate_db_search_doc_to_server_search_doc
|
||||
from danswer.db.chat import update_search_docs_table_with_relevance
|
||||
from danswer.db.engine import get_session_context_manager
|
||||
from danswer.db.models import User
|
||||
from danswer.db.persona import get_prompt_by_id
|
||||
@@ -48,6 +50,7 @@ from danswer.server.query_and_chat.models import ChatMessageDetail
|
||||
from danswer.server.utils import get_json_line
|
||||
from danswer.tools.force import ForceUseTool
|
||||
from danswer.tools.search.search_tool import SEARCH_DOC_CONTENT_ID
|
||||
from danswer.tools.search.search_tool import SEARCH_EVALUATION_ID
|
||||
from danswer.tools.search.search_tool import SEARCH_RESPONSE_SUMMARY_ID
|
||||
from danswer.tools.search.search_tool import SearchResponseSummary
|
||||
from danswer.tools.search.search_tool import SearchTool
|
||||
@@ -57,6 +60,7 @@ from danswer.tools.tool_runner import ToolCallKickoff
|
||||
from danswer.utils.logger import setup_logger
|
||||
from danswer.utils.timing import log_generator_function_time
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
AnswerObjectIterator = Iterator[
|
||||
@@ -70,6 +74,7 @@ AnswerObjectIterator = Iterator[
|
||||
| ChatMessageDetail
|
||||
| CitationInfo
|
||||
| ToolCallKickoff
|
||||
| LLMRelevanceSummaryResponse
|
||||
]
|
||||
|
||||
|
||||
@@ -88,8 +93,9 @@ def stream_answer_objects(
|
||||
bypass_acl: bool = False,
|
||||
use_citations: bool = False,
|
||||
danswerbot_flow: bool = False,
|
||||
retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None]
|
||||
| None = None,
|
||||
retrieval_metrics_callback: (
|
||||
Callable[[RetrievalMetricsContainer], None] | None
|
||||
) = None,
|
||||
rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None,
|
||||
) -> AnswerObjectIterator:
|
||||
"""Streams in order:
|
||||
@@ -127,6 +133,7 @@ def stream_answer_objects(
|
||||
user_query=query_msg.message,
|
||||
history_str=history_str,
|
||||
)
|
||||
|
||||
# Given back ahead of the documents for latency reasons
|
||||
# In chat flow it's given back along with the documents
|
||||
yield QueryRephrase(rephrased_query=rephrased_query)
|
||||
@@ -182,6 +189,7 @@ def stream_answer_objects(
|
||||
chunks_below=query_req.chunks_below,
|
||||
full_doc=query_req.full_doc,
|
||||
bypass_acl=bypass_acl,
|
||||
llm_doc_eval=query_req.llm_doc_eval,
|
||||
)
|
||||
|
||||
answer_config = AnswerStyleConfig(
|
||||
@@ -189,6 +197,7 @@ def stream_answer_objects(
|
||||
quotes_config=QuotesConfig() if not use_citations else None,
|
||||
document_pruning_config=document_pruning_config,
|
||||
)
|
||||
|
||||
answer = Answer(
|
||||
question=query_msg.message,
|
||||
answer_style_config=answer_config,
|
||||
@@ -204,12 +213,16 @@ def stream_answer_objects(
|
||||
# tested quotes with tool calling too much yet
|
||||
skip_explicit_tool_calling=True,
|
||||
return_contexts=query_req.return_contexts,
|
||||
skip_gen_ai_answer_generation=query_req.skip_gen_ai_answer_generation,
|
||||
)
|
||||
|
||||
# won't be any ImageGenerationDisplay responses since that tool is never passed in
|
||||
dropped_inds: list[int] = []
|
||||
|
||||
for packet in cast(AnswerObjectIterator, answer.processed_streamed_output):
|
||||
# for one-shot flow, don't currently do anything with these
|
||||
if isinstance(packet, ToolResponse):
|
||||
# (likely fine that it comes after the initial creation of the search docs)
|
||||
if packet.id == SEARCH_RESPONSE_SUMMARY_ID:
|
||||
search_response_summary = cast(SearchResponseSummary, packet.response)
|
||||
|
||||
@@ -242,6 +255,7 @@ def stream_answer_objects(
|
||||
recency_bias_multiplier=search_response_summary.recency_bias_multiplier,
|
||||
)
|
||||
yield initial_response
|
||||
|
||||
elif packet.id == SECTION_RELEVANCE_LIST_ID:
|
||||
chunk_indices = packet.response
|
||||
|
||||
@@ -253,8 +267,21 @@ def stream_answer_objects(
|
||||
)
|
||||
|
||||
yield LLMRelevanceFilterResponse(relevant_chunk_indices=packet.response)
|
||||
|
||||
elif packet.id == SEARCH_DOC_CONTENT_ID:
|
||||
yield packet.response
|
||||
|
||||
elif packet.id == SEARCH_EVALUATION_ID:
|
||||
evaluation_response = LLMRelevanceSummaryResponse(
|
||||
relevance_summaries=packet.response
|
||||
)
|
||||
if reference_db_search_docs is not None:
|
||||
update_search_docs_table_with_relevance(
|
||||
db_session=db_session,
|
||||
reference_db_search_docs=reference_db_search_docs,
|
||||
relevance_summary=evaluation_response,
|
||||
)
|
||||
yield evaluation_response
|
||||
else:
|
||||
yield packet
|
||||
|
||||
@@ -275,7 +302,6 @@ def stream_answer_objects(
|
||||
msg_detail_response = translate_db_message_to_chat_message_detail(
|
||||
gen_ai_response_message
|
||||
)
|
||||
|
||||
yield msg_detail_response
|
||||
|
||||
|
||||
@@ -309,8 +335,9 @@ def get_search_answer(
|
||||
bypass_acl: bool = False,
|
||||
use_citations: bool = False,
|
||||
danswerbot_flow: bool = False,
|
||||
retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None]
|
||||
| None = None,
|
||||
retrieval_metrics_callback: (
|
||||
Callable[[RetrievalMetricsContainer], None] | None
|
||||
) = None,
|
||||
rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None,
|
||||
) -> OneShotQAResponse:
|
||||
"""Collects the streamed one shot answer responses into a single object"""
|
||||
|
||||
@@ -27,12 +27,19 @@ class DirectQARequest(ChunkContext):
|
||||
messages: list[ThreadMessage]
|
||||
prompt_id: int | None
|
||||
persona_id: int
|
||||
agentic: bool | None = None
|
||||
retrieval_options: RetrievalDetails = Field(default_factory=RetrievalDetails)
|
||||
# This is to forcibly skip (or run) the step, if None it uses the system defaults
|
||||
skip_rerank: bool | None = None
|
||||
skip_llm_chunk_filter: bool | None = None
|
||||
chain_of_thought: bool = False
|
||||
return_contexts: bool = False
|
||||
# This is to toggle agentic evaluation:
|
||||
# 1. Evaluates whether each response is relevant or not
|
||||
# 2. Provides a summary of the document's relevance in the resulsts
|
||||
llm_doc_eval: bool = False
|
||||
# If True, skips generative an AI response to the search query
|
||||
skip_gen_ai_answer_generation: bool = False
|
||||
|
||||
@root_validator
|
||||
def check_chain_of_thought_and_prompt_id(
|
||||
|
||||
43
backend/danswer/prompts/agentic_evaluation.py
Normal file
43
backend/danswer/prompts/agentic_evaluation.py
Normal file
@@ -0,0 +1,43 @@
|
||||
AGENTIC_SEARCH_SYSTEM_PROMPT = """
|
||||
You are an expert at evaluating the relevance of a document to a search query.
|
||||
Provided a document and a search query, you determine if the document is relevant to the user query.
|
||||
You ALWAYS output the 3 sections described below and every section always begins with the same header line.
|
||||
The "Chain of Thought" is to help you understand the document and query and their relevance to one another.
|
||||
The "Useful Analysis" is shown to the user to help them understand why the document is or is not useful for them.
|
||||
The "Final Relevance Determination" is always a single True or False.
|
||||
|
||||
You always output your response following these 3 sections:
|
||||
|
||||
1. Chain of Thought:
|
||||
Provide a chain of thought analysis considering:
|
||||
- The main purpose and content of the document
|
||||
- What the user is searching for
|
||||
- How the document relates to the query
|
||||
- Potential uses of the document for the given query
|
||||
Be thorough, but avoid unnecessary repetition. Think step by step.
|
||||
|
||||
2. Useful Analysis:
|
||||
Summarize the contents of the document as it relates to the user query.
|
||||
BE ABSOLUTELY AS CONCISE AS POSSIBLE.
|
||||
If the document is not useful, briefly mention the what the document is about.
|
||||
Do NOT say whether this document is useful or not useful, ONLY provide the summary.
|
||||
If referring to the document, prefer using "this" document over "the" document.
|
||||
|
||||
3. Final Relevance Determination:
|
||||
True or False
|
||||
"""
|
||||
|
||||
AGENTIC_SEARCH_USER_PROMPT = """
|
||||
Document:
|
||||
```
|
||||
{content}
|
||||
```
|
||||
|
||||
Query:
|
||||
{query}
|
||||
|
||||
Be sure to run through the 3 steps of evaluation:
|
||||
1. Chain of Thought
|
||||
2. Useful Analysis
|
||||
3. Final Relevance Determination
|
||||
""".strip()
|
||||
@@ -130,11 +130,14 @@ class InferenceChunk(BaseChunk):
|
||||
recency_bias: float
|
||||
score: float | None
|
||||
hidden: bool
|
||||
is_relevant: bool | None = None
|
||||
relevance_explanation: str | None = None
|
||||
metadata: dict[str, str | list[str]]
|
||||
# Matched sections in the chunk. Uses Vespa syntax e.g. <hi>TEXT</hi>
|
||||
# to specify that a set of words should be highlighted. For example:
|
||||
# ["<hi>the</hi> <hi>answer</hi> is 42", "he couldn't find an <hi>answer</hi>"]
|
||||
match_highlights: list[str]
|
||||
|
||||
# when the doc was last updated
|
||||
updated_at: datetime | None
|
||||
primary_owners: list[str] | None = None
|
||||
@@ -227,6 +230,8 @@ class SearchDoc(BaseModel):
|
||||
hidden: bool
|
||||
metadata: dict[str, str | list[str]]
|
||||
score: float | None
|
||||
is_relevant: bool | None = None
|
||||
relevance_explanation: str | None = None
|
||||
# Matched sections in the doc. Uses Vespa syntax e.g. <hi>TEXT</hi>
|
||||
# to specify that a set of words should be highlighted. For example:
|
||||
# ["<hi>the</hi> <hi>answer</hi> is 42", "the answer is <hi>42</hi>""]
|
||||
|
||||
@@ -5,10 +5,14 @@ from typing import cast
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.chat.models import RelevanceChunk
|
||||
from danswer.configs.chat_configs import DISABLE_AGENTIC_SEARCH
|
||||
from danswer.configs.chat_configs import MULTILINGUAL_QUERY_EXPANSION
|
||||
from danswer.db.embedding_model import get_current_db_embedding_model
|
||||
from danswer.db.models import User
|
||||
from danswer.document_index.factory import get_default_document_index
|
||||
from danswer.llm.answering.models import DocumentPruningConfig
|
||||
from danswer.llm.answering.models import PromptConfig
|
||||
from danswer.llm.answering.prune_and_merge import ChunkRange
|
||||
from danswer.llm.answering.prune_and_merge import merge_chunk_intervals
|
||||
from danswer.llm.interfaces import LLM
|
||||
@@ -25,7 +29,10 @@ from danswer.search.postprocessing.postprocessing import search_postprocessing
|
||||
from danswer.search.preprocessing.preprocessing import retrieval_preprocessing
|
||||
from danswer.search.retrieval.search_runner import retrieve_chunks
|
||||
from danswer.search.utils import inference_section_from_chunks
|
||||
from danswer.secondary_llm_flows.agentic_evaluation import evaluate_inference_section
|
||||
from danswer.utils.logger import setup_logger
|
||||
from danswer.utils.threadpool_concurrency import FunctionCall
|
||||
from danswer.utils.threadpool_concurrency import run_functions_in_parallel
|
||||
from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -40,9 +47,12 @@ class SearchPipeline:
|
||||
fast_llm: LLM,
|
||||
db_session: Session,
|
||||
bypass_acl: bool = False, # NOTE: VERY DANGEROUS, USE WITH CAUTION
|
||||
retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None]
|
||||
| None = None,
|
||||
retrieval_metrics_callback: (
|
||||
Callable[[RetrievalMetricsContainer], None] | None
|
||||
) = None,
|
||||
rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None,
|
||||
prompt_config: PromptConfig | None = None,
|
||||
pruning_config: DocumentPruningConfig | None = None,
|
||||
):
|
||||
self.search_request = search_request
|
||||
self.user = user
|
||||
@@ -58,6 +68,8 @@ class SearchPipeline:
|
||||
primary_index_name=self.embedding_model.index_name,
|
||||
secondary_index_name=None,
|
||||
)
|
||||
self.prompt_config: PromptConfig | None = prompt_config
|
||||
self.pruning_config: DocumentPruningConfig | None = pruning_config
|
||||
|
||||
# Preprocessing steps generate this
|
||||
self._search_query: SearchQuery | None = None
|
||||
@@ -74,9 +86,9 @@ class SearchPipeline:
|
||||
self._relevant_section_indices: list[int] | None = None
|
||||
|
||||
# Generates reranked chunks and LLM selections
|
||||
self._postprocessing_generator: Iterator[
|
||||
list[InferenceSection] | list[int]
|
||||
] | None = None
|
||||
self._postprocessing_generator: (
|
||||
Iterator[list[InferenceSection] | list[int]] | None
|
||||
) = None
|
||||
|
||||
"""Pre-processing"""
|
||||
|
||||
@@ -323,6 +335,32 @@ class SearchPipeline:
|
||||
)
|
||||
return self._relevant_section_indices
|
||||
|
||||
@property
|
||||
def relevance_summaries(self) -> dict[str, RelevanceChunk]:
|
||||
if DISABLE_AGENTIC_SEARCH:
|
||||
raise ValueError(
|
||||
"Agentic saerch operation called while DISABLE_AGENTIC_SEARCH is toggled"
|
||||
)
|
||||
if len(self.reranked_sections) == 0:
|
||||
logger.warning(
|
||||
"No sections found in agentic search evalution. Returning empty dict."
|
||||
)
|
||||
return {}
|
||||
|
||||
sections = self.reranked_sections
|
||||
functions = [
|
||||
FunctionCall(
|
||||
evaluate_inference_section, (section, self.search_query.query, self.llm)
|
||||
)
|
||||
for section in sections
|
||||
]
|
||||
|
||||
results = run_functions_in_parallel(function_calls=functions)
|
||||
|
||||
return {
|
||||
next(iter(value)): value[next(iter(value))] for value in results.values()
|
||||
}
|
||||
|
||||
@property
|
||||
def section_relevance_list(self) -> list[bool]:
|
||||
return [
|
||||
|
||||
@@ -278,17 +278,17 @@ def search_postprocessing(
|
||||
_log_top_section_links(search_query.search_type.value, reranked_sections)
|
||||
yield reranked_sections
|
||||
|
||||
llm_section_selection = cast(
|
||||
list[str] | None,
|
||||
post_processing_results.get(str(llm_filter_task_id))
|
||||
if llm_filter_task_id
|
||||
else None,
|
||||
)
|
||||
if llm_section_selection is not None:
|
||||
yield [
|
||||
index
|
||||
for index, section in enumerate(reranked_sections or retrieved_sections)
|
||||
if section.center_chunk.unique_id in llm_section_selection
|
||||
llm_selected_section_ids = (
|
||||
[
|
||||
section.center_chunk.unique_id
|
||||
for section in post_processing_results.get(str(llm_filter_task_id), [])
|
||||
]
|
||||
else:
|
||||
yield cast(list[int], [])
|
||||
if llm_filter_task_id
|
||||
else []
|
||||
)
|
||||
|
||||
yield [
|
||||
index
|
||||
for index, section in enumerate(reranked_sections or retrieved_sections)
|
||||
if section.center_chunk.unique_id in llm_selected_section_ids
|
||||
]
|
||||
|
||||
70
backend/danswer/secondary_llm_flows/agentic_evaluation.py
Normal file
70
backend/danswer/secondary_llm_flows/agentic_evaluation.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import re
|
||||
|
||||
from danswer.chat.models import RelevanceChunk
|
||||
from danswer.llm.interfaces import LLM
|
||||
from danswer.llm.utils import dict_based_prompt_to_langchain_prompt
|
||||
from danswer.llm.utils import message_to_string
|
||||
from danswer.prompts.agentic_evaluation import AGENTIC_SEARCH_SYSTEM_PROMPT
|
||||
from danswer.prompts.agentic_evaluation import AGENTIC_SEARCH_USER_PROMPT
|
||||
from danswer.search.models import InferenceSection
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def _get_agent_eval_messages(
|
||||
title: str, content: str, query: str
|
||||
) -> list[dict[str, str]]:
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": AGENTIC_SEARCH_SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": AGENTIC_SEARCH_USER_PROMPT.format(
|
||||
title=title, content=content, query=query
|
||||
),
|
||||
},
|
||||
]
|
||||
return messages
|
||||
|
||||
|
||||
def evaluate_inference_section(
|
||||
document: InferenceSection, query: str, llm: LLM
|
||||
) -> dict[str, RelevanceChunk]:
|
||||
results = {}
|
||||
|
||||
document_id = document.center_chunk.document_id
|
||||
semantic_id = document.center_chunk.semantic_identifier
|
||||
contents = document.combined_content
|
||||
chunk_id = document.center_chunk.chunk_id
|
||||
|
||||
messages = _get_agent_eval_messages(
|
||||
title=semantic_id, content=contents, query=query
|
||||
)
|
||||
filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages)
|
||||
model_output = message_to_string(llm.invoke(filled_llm_prompt))
|
||||
|
||||
# Search for the "Useful Analysis" section in the model output
|
||||
# This regex looks for "2. Useful Analysis" (case-insensitive) followed by an optional colon,
|
||||
# then any text up to "3. Final Relevance"
|
||||
# The (?i) flag makes it case-insensitive, and re.DOTALL allows the dot to match newlines
|
||||
# If no match is found, the entire model output is used as the analysis
|
||||
analysis_match = re.search(
|
||||
r"(?i)2\.\s*useful analysis:?\s*(.+?)\n\n3\.\s*final relevance",
|
||||
model_output,
|
||||
re.DOTALL,
|
||||
)
|
||||
analysis = analysis_match.group(1).strip() if analysis_match else model_output
|
||||
|
||||
# Get the last non-empty line
|
||||
last_line = next(
|
||||
(line for line in reversed(model_output.split("\n")) if line.strip()), ""
|
||||
)
|
||||
relevant = last_line.strip().lower().startswith("true")
|
||||
|
||||
results[f"{document_id}-{chunk_id}"] = RelevanceChunk(
|
||||
relevant=relevant, content=analysis
|
||||
)
|
||||
return results
|
||||
@@ -303,7 +303,6 @@ def handle_new_chat_message(
|
||||
request.headers
|
||||
),
|
||||
)
|
||||
|
||||
return StreamingResponse(packets, media_type="application/json")
|
||||
|
||||
|
||||
@@ -484,6 +483,7 @@ def upload_files_for_chat(
|
||||
"text/tab-separated-values",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"application/x-yaml",
|
||||
}
|
||||
document_content_types = {
|
||||
|
||||
@@ -171,7 +171,6 @@ class SearchFeedbackRequest(BaseModel):
|
||||
|
||||
if click is False and feedback is None:
|
||||
raise ValueError("Empty feedback received.")
|
||||
|
||||
return values
|
||||
|
||||
|
||||
@@ -186,6 +185,7 @@ class ChatMessageDetail(BaseModel):
|
||||
time_sent: datetime
|
||||
alternate_assistant_id: str | None
|
||||
# Dict mapping citation number to db_doc_id
|
||||
chat_session_id: int | None = None
|
||||
citations: dict[int, int] | None
|
||||
files: list[FileDescriptor]
|
||||
tool_calls: list[ToolCallFinalResult]
|
||||
@@ -196,6 +196,13 @@ class ChatMessageDetail(BaseModel):
|
||||
return initial_dict
|
||||
|
||||
|
||||
class SearchSessionDetailResponse(BaseModel):
|
||||
search_session_id: int
|
||||
description: str
|
||||
documents: list[SearchDoc]
|
||||
messages: list[ChatMessageDetail]
|
||||
|
||||
|
||||
class ChatSessionDetailResponse(BaseModel):
|
||||
chat_session_id: int
|
||||
description: str
|
||||
|
||||
@@ -7,6 +7,14 @@ from sqlalchemy.orm import Session
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.db.chat import get_chat_messages_by_session
|
||||
from danswer.db.chat import get_chat_session_by_id
|
||||
from danswer.db.chat import get_chat_sessions_by_user
|
||||
from danswer.db.chat import get_first_messages_for_chat_sessions
|
||||
from danswer.db.chat import get_search_docs_for_chat_message
|
||||
from danswer.db.chat import translate_db_message_to_chat_message_detail
|
||||
from danswer.db.chat import translate_db_search_doc_to_server_search_doc
|
||||
from danswer.db.embedding_model import get_current_db_embedding_model
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.models import User
|
||||
@@ -24,8 +32,11 @@ from danswer.secondary_llm_flows.query_validation import get_query_answerability
|
||||
from danswer.secondary_llm_flows.query_validation import stream_query_answerability
|
||||
from danswer.server.query_and_chat.models import AdminSearchRequest
|
||||
from danswer.server.query_and_chat.models import AdminSearchResponse
|
||||
from danswer.server.query_and_chat.models import ChatSessionDetails
|
||||
from danswer.server.query_and_chat.models import ChatSessionsResponse
|
||||
from danswer.server.query_and_chat.models import HelperResponse
|
||||
from danswer.server.query_and_chat.models import QueryValidationResponse
|
||||
from danswer.server.query_and_chat.models import SearchSessionDetailResponse
|
||||
from danswer.server.query_and_chat.models import SimpleQueryRequest
|
||||
from danswer.server.query_and_chat.models import SourceTag
|
||||
from danswer.server.query_and_chat.models import TagResponse
|
||||
@@ -46,7 +57,6 @@ def admin_search(
|
||||
) -> AdminSearchResponse:
|
||||
query = question.query
|
||||
logger.info(f"Received admin search query: {query}")
|
||||
|
||||
user_acl_filters = build_access_filters_for_user(user, db_session)
|
||||
final_filters = IndexFilters(
|
||||
source_type=question.filters.source_type,
|
||||
@@ -55,19 +65,15 @@ def admin_search(
|
||||
tags=question.filters.tags,
|
||||
access_control_list=user_acl_filters,
|
||||
)
|
||||
|
||||
embedding_model = get_current_db_embedding_model(db_session)
|
||||
|
||||
document_index = get_default_document_index(
|
||||
primary_index_name=embedding_model.index_name, secondary_index_name=None
|
||||
)
|
||||
|
||||
if not isinstance(document_index, VespaIndex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot use admin-search when using a non-Vespa document index",
|
||||
)
|
||||
|
||||
matching_chunks = document_index.admin_retrieval(query=query, filters=final_filters)
|
||||
|
||||
documents = chunks_or_sections_to_search_docs(matching_chunks)
|
||||
@@ -136,6 +142,103 @@ def query_validation(
|
||||
return QueryValidationResponse(reasoning=reasoning, answerable=answerable)
|
||||
|
||||
|
||||
@basic_router.get("/user-searches")
|
||||
def get_user_search_sessions(
|
||||
user: User | None = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ChatSessionsResponse:
|
||||
user_id = user.id if user is not None else None
|
||||
|
||||
try:
|
||||
search_sessions = get_chat_sessions_by_user(
|
||||
user_id=user_id, deleted=False, db_session=db_session, only_one_shot=True
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Chat session does not exist or has been deleted"
|
||||
)
|
||||
|
||||
search_session_ids = [chat.id for chat in search_sessions]
|
||||
first_messages = get_first_messages_for_chat_sessions(
|
||||
search_session_ids, db_session
|
||||
)
|
||||
first_messages_dict = dict(first_messages)
|
||||
|
||||
response = ChatSessionsResponse(
|
||||
sessions=[
|
||||
ChatSessionDetails(
|
||||
id=search.id,
|
||||
name=first_messages_dict.get(search.id, search.description),
|
||||
persona_id=search.persona_id,
|
||||
time_created=search.time_created.isoformat(),
|
||||
shared_status=search.shared_status,
|
||||
folder_id=search.folder_id,
|
||||
current_alternate_model=search.current_alternate_model,
|
||||
)
|
||||
for search in search_sessions
|
||||
]
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@basic_router.get("/search-session/{session_id}")
|
||||
def get_search_session(
|
||||
session_id: int,
|
||||
is_shared: bool = False,
|
||||
user: User | None = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> SearchSessionDetailResponse:
|
||||
user_id = user.id if user is not None else None
|
||||
|
||||
try:
|
||||
search_session = get_chat_session_by_id(
|
||||
chat_session_id=session_id,
|
||||
user_id=user_id,
|
||||
db_session=db_session,
|
||||
is_shared=is_shared,
|
||||
)
|
||||
except ValueError:
|
||||
raise ValueError("Search session does not exist or has been deleted")
|
||||
|
||||
session_messages = get_chat_messages_by_session(
|
||||
chat_session_id=session_id,
|
||||
user_id=user_id,
|
||||
db_session=db_session,
|
||||
# we already did a permission check above with the call to
|
||||
# `get_chat_session_by_id`, so we can skip it here
|
||||
skip_permission_check=True,
|
||||
# we need the tool call objs anyways, so just fetch them in a single call
|
||||
prefetch_tool_calls=True,
|
||||
)
|
||||
docs_response: list[SearchDoc] = []
|
||||
for message in session_messages:
|
||||
if (
|
||||
message.message_type == MessageType.ASSISTANT
|
||||
or message.message_type == MessageType.SYSTEM
|
||||
):
|
||||
docs = get_search_docs_for_chat_message(
|
||||
db_session=db_session, chat_message_id=message.id
|
||||
)
|
||||
for doc in docs:
|
||||
server_doc = translate_db_search_doc_to_server_search_doc(doc)
|
||||
docs_response.append(server_doc)
|
||||
|
||||
response = SearchSessionDetailResponse(
|
||||
search_session_id=session_id,
|
||||
description=search_session.description,
|
||||
documents=docs_response,
|
||||
messages=[
|
||||
translate_db_message_to_chat_message_detail(
|
||||
msg, remove_doc_content=is_shared # if shared, don't leak doc content
|
||||
)
|
||||
for msg in session_messages
|
||||
],
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# NOTE No longer used, after search/chat redesign.
|
||||
# No search responses are answered with a conversational generative AI response
|
||||
@basic_router.post("/stream-query-validation")
|
||||
def stream_query_validation(
|
||||
simple_query: SimpleQueryRequest, _: User = Depends(current_user)
|
||||
@@ -156,6 +259,7 @@ def get_answer_with_quote(
|
||||
_: None = Depends(check_token_rate_limits),
|
||||
) -> StreamingResponse:
|
||||
query = query_request.messages[0].message
|
||||
|
||||
logger.info(f"Received query for one shot answer with quotes: {query}")
|
||||
packets = stream_search_answer(
|
||||
query_req=query_request,
|
||||
|
||||
@@ -10,6 +10,7 @@ from danswer.chat.chat_utils import llm_doc_from_inference_section
|
||||
from danswer.chat.models import DanswerContext
|
||||
from danswer.chat.models import DanswerContexts
|
||||
from danswer.chat.models import LlmDoc
|
||||
from danswer.configs.chat_configs import DISABLE_AGENTIC_SEARCH
|
||||
from danswer.db.models import Persona
|
||||
from danswer.db.models import User
|
||||
from danswer.dynamic_configs.interface import JSON_ro
|
||||
@@ -30,11 +31,15 @@ from danswer.secondary_llm_flows.query_expansion import history_based_query_reph
|
||||
from danswer.tools.search.search_utils import llm_doc_to_dict
|
||||
from danswer.tools.tool import Tool
|
||||
from danswer.tools.tool import ToolResponse
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
SEARCH_RESPONSE_SUMMARY_ID = "search_response_summary"
|
||||
SEARCH_DOC_CONTENT_ID = "search_doc_content"
|
||||
SECTION_RELEVANCE_LIST_ID = "section_relevance_list"
|
||||
FINAL_CONTEXT_DOCUMENTS = "final_context_documents"
|
||||
SEARCH_EVALUATION_ID = "llm_doc_eval"
|
||||
|
||||
|
||||
class SearchResponseSummary(BaseModel):
|
||||
@@ -80,6 +85,7 @@ class SearchTool(Tool):
|
||||
chunks_below: int = 0,
|
||||
full_doc: bool = False,
|
||||
bypass_acl: bool = False,
|
||||
llm_doc_eval: bool = False,
|
||||
) -> None:
|
||||
self.user = user
|
||||
self.persona = persona
|
||||
@@ -96,6 +102,7 @@ class SearchTool(Tool):
|
||||
self.full_doc = full_doc
|
||||
self.bypass_acl = bypass_acl
|
||||
self.db_session = db_session
|
||||
self.llm_doc_eval = llm_doc_eval
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -218,23 +225,28 @@ class SearchTool(Tool):
|
||||
self.retrieval_options.filters if self.retrieval_options else None
|
||||
),
|
||||
persona=self.persona,
|
||||
offset=self.retrieval_options.offset
|
||||
if self.retrieval_options
|
||||
else None,
|
||||
offset=(
|
||||
self.retrieval_options.offset if self.retrieval_options else None
|
||||
),
|
||||
limit=self.retrieval_options.limit if self.retrieval_options else None,
|
||||
chunks_above=self.chunks_above,
|
||||
chunks_below=self.chunks_below,
|
||||
full_doc=self.full_doc,
|
||||
enable_auto_detect_filters=self.retrieval_options.enable_auto_detect_filters
|
||||
if self.retrieval_options
|
||||
else None,
|
||||
enable_auto_detect_filters=(
|
||||
self.retrieval_options.enable_auto_detect_filters
|
||||
if self.retrieval_options
|
||||
else None
|
||||
),
|
||||
),
|
||||
user=self.user,
|
||||
llm=self.llm,
|
||||
fast_llm=self.fast_llm,
|
||||
bypass_acl=self.bypass_acl,
|
||||
db_session=self.db_session,
|
||||
prompt_config=self.prompt_config,
|
||||
pruning_config=self.pruning_config,
|
||||
)
|
||||
|
||||
yield ToolResponse(
|
||||
id=SEARCH_RESPONSE_SUMMARY_ID,
|
||||
response=SearchResponseSummary(
|
||||
@@ -246,6 +258,7 @@ class SearchTool(Tool):
|
||||
recency_bias_multiplier=search_pipeline.search_query.recency_bias_multiplier,
|
||||
),
|
||||
)
|
||||
|
||||
yield ToolResponse(
|
||||
id=SEARCH_DOC_CONTENT_ID,
|
||||
response=DanswerContexts(
|
||||
@@ -260,6 +273,7 @@ class SearchTool(Tool):
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
yield ToolResponse(
|
||||
id=SECTION_RELEVANCE_LIST_ID,
|
||||
response=search_pipeline.relevant_section_indices,
|
||||
@@ -281,6 +295,11 @@ class SearchTool(Tool):
|
||||
|
||||
yield ToolResponse(id=FINAL_CONTEXT_DOCUMENTS, response=llm_docs)
|
||||
|
||||
if self.llm_doc_eval and not DISABLE_AGENTIC_SEARCH:
|
||||
yield ToolResponse(
|
||||
id=SEARCH_EVALUATION_ID, response=search_pipeline.relevance_summaries
|
||||
)
|
||||
|
||||
def final_result(self, *args: ToolResponse) -> JSON_ro:
|
||||
final_docs = cast(
|
||||
list[LlmDoc],
|
||||
|
||||
@@ -6,7 +6,13 @@ from danswer.llm.utils import get_default_llm_tokenizer
|
||||
from danswer.tools.tool import Tool
|
||||
|
||||
|
||||
OPEN_AI_TOOL_CALLING_MODELS = {"gpt-3.5-turbo", "gpt-4-turbo", "gpt-4"}
|
||||
OPEN_AI_TOOL_CALLING_MODELS = {
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
}
|
||||
|
||||
|
||||
def explicit_tool_calling_supported(model_provider: str, model_name: str) -> bool:
|
||||
|
||||
@@ -87,6 +87,7 @@ def run_functions_in_parallel(
|
||||
are the result_id of the FunctionCall and the values are the results of the call.
|
||||
"""
|
||||
results = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(function_calls)) as executor:
|
||||
future_to_id = {
|
||||
executor.submit(func_call.execute): func_call.result_id
|
||||
|
||||
@@ -33,6 +33,7 @@ def translate_doc_response_to_simple_doc(
|
||||
) -> list[SimpleDoc]:
|
||||
return [
|
||||
SimpleDoc(
|
||||
id=doc.document_id,
|
||||
semantic_identifier=doc.semantic_identifier,
|
||||
link=doc.link,
|
||||
blurb=doc.blurb,
|
||||
|
||||
@@ -44,6 +44,7 @@ class BasicCreateChatMessageRequest(ChunkContext):
|
||||
|
||||
|
||||
class SimpleDoc(BaseModel):
|
||||
id: str
|
||||
semantic_identifier: str
|
||||
link: str | None
|
||||
blurb: str
|
||||
|
||||
@@ -33,6 +33,7 @@ def process_question(danswer_url: str, question: str, api_key: str | None) -> No
|
||||
"message": question,
|
||||
"chat_session_id": chat_session_id,
|
||||
"parent_message_id": None,
|
||||
"file_descriptors": [],
|
||||
# Default Question Answer prompt
|
||||
"prompt_id": 0,
|
||||
# Not specifying any specific docs to chat to, we want to run a search
|
||||
|
||||
@@ -2,6 +2,8 @@ from openai import OpenAI
|
||||
|
||||
|
||||
VALID_MODEL_LIST = [
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o",
|
||||
"gpt-4-1106-preview",
|
||||
"gpt-4-vision-preview",
|
||||
"gpt-4",
|
||||
|
||||
6
backend/shared_configs/enums.py
Normal file
6
backend/shared_configs/enums.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EmbedTextType(str, Enum):
|
||||
QUERY = "query"
|
||||
PASSAGE = "passage"
|
||||
@@ -53,8 +53,10 @@ Edit `search_test_config.yaml` to set:
|
||||
- The path to the zip file containing the files you'd like to test against
|
||||
- questions_file
|
||||
- The path to the yaml containing the questions you'd like to test with
|
||||
- branch
|
||||
- Set the branch to null if you want it to just use the code as is
|
||||
- commit_sha
|
||||
- Set this to the SHA of the commit you want to run the test against
|
||||
- You must clear all local changes if you want to use this option
|
||||
- Set this to null if you want it to just use the code as is
|
||||
- clean_up_docker_containers
|
||||
- Set this to true to automatically delete all docker containers, networks and volumes after the test
|
||||
- launch_web_ui
|
||||
@@ -68,10 +70,10 @@ Edit `search_test_config.yaml` to set:
|
||||
- model_server_port
|
||||
- This is the port of the remote model server
|
||||
- Only need to set this if use_cloud_gpu is true
|
||||
- existing_test_suffix
|
||||
- existing_test_suffix (THIS IS NOT A SUFFIX ANYMORE, TODO UPDATE THE DOCS HERE)
|
||||
- Use this if you would like to relaunch a previous test instance
|
||||
- Input the suffix of the test you'd like to re-launch
|
||||
- (E.g. to use the data from folder "test_1234_5678" put "_1234_5678")
|
||||
- (E.g. to use the data from folder "test-1234-5678" put "-1234-5678")
|
||||
- No new files will automatically be uploaded
|
||||
- Leave empty to run a new test
|
||||
- limit
|
||||
|
||||
0
backend/tests/regression/answer_quality/__init__.py
Normal file
0
backend/tests/regression/answer_quality/__init__.py
Normal file
@@ -2,25 +2,44 @@ import requests
|
||||
from retry import retry
|
||||
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.connectors.models import InputType
|
||||
from danswer.db.enums import IndexingStatus
|
||||
from danswer.one_shot_answer.models import DirectQARequest
|
||||
from danswer.one_shot_answer.models import ThreadMessage
|
||||
from danswer.search.models import IndexFilters
|
||||
from danswer.search.models import OptionalSearchSetting
|
||||
from danswer.search.models import RetrievalDetails
|
||||
from danswer.server.documents.models import ConnectorBase
|
||||
from tests.regression.answer_quality.cli_utils import (
|
||||
get_api_server_host_port,
|
||||
)
|
||||
from danswer.server.query_and_chat.models import ChatSessionCreationRequest
|
||||
from ee.danswer.server.query_and_chat.models import BasicCreateChatMessageRequest
|
||||
from tests.regression.answer_quality.cli_utils import get_api_server_host_port
|
||||
|
||||
GENERAL_HEADERS = {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _api_url_builder(run_suffix: str, api_path: str) -> str:
|
||||
return f"http://localhost:{get_api_server_host_port(run_suffix)}" + api_path
|
||||
|
||||
|
||||
@retry(tries=5, delay=2, backoff=2)
|
||||
def _create_new_chat_session(run_suffix: str) -> int:
|
||||
create_chat_request = ChatSessionCreationRequest(
|
||||
persona_id=0,
|
||||
description=None,
|
||||
)
|
||||
body = create_chat_request.dict()
|
||||
|
||||
create_chat_url = _api_url_builder(run_suffix, "/chat/create-chat-session/")
|
||||
|
||||
response_json = requests.post(
|
||||
create_chat_url, headers=GENERAL_HEADERS, json=body
|
||||
).json()
|
||||
chat_session_id = response_json.get("chat_session_id")
|
||||
|
||||
if isinstance(chat_session_id, int):
|
||||
return chat_session_id
|
||||
else:
|
||||
raise RuntimeError(response_json)
|
||||
|
||||
|
||||
@retry(tries=10, delay=10)
|
||||
def get_answer_from_query(query: str, run_suffix: str) -> tuple[list[str], str]:
|
||||
filters = IndexFilters(
|
||||
source_type=None,
|
||||
@@ -29,49 +48,49 @@ def get_answer_from_query(query: str, run_suffix: str) -> tuple[list[str], str]:
|
||||
tags=None,
|
||||
access_control_list=None,
|
||||
)
|
||||
|
||||
messages = [ThreadMessage(message=query, sender=None, role=MessageType.USER)]
|
||||
|
||||
new_message_request = DirectQARequest(
|
||||
messages=messages,
|
||||
prompt_id=0,
|
||||
persona_id=0,
|
||||
retrieval_options=RetrievalDetails(
|
||||
run_search=OptionalSearchSetting.ALWAYS,
|
||||
real_time=True,
|
||||
filters=filters,
|
||||
enable_auto_detect_filters=False,
|
||||
),
|
||||
chain_of_thought=False,
|
||||
return_contexts=True,
|
||||
retrieval_options = RetrievalDetails(
|
||||
run_search=OptionalSearchSetting.ALWAYS,
|
||||
real_time=True,
|
||||
filters=filters,
|
||||
enable_auto_detect_filters=False,
|
||||
)
|
||||
|
||||
url = _api_url_builder(run_suffix, "/query/answer-with-quote/")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
chat_session_id = _create_new_chat_session(run_suffix)
|
||||
|
||||
url = _api_url_builder(run_suffix, "/chat/send-message-simple-api/")
|
||||
|
||||
new_message_request = BasicCreateChatMessageRequest(
|
||||
chat_session_id=chat_session_id,
|
||||
message=query,
|
||||
retrieval_options=retrieval_options,
|
||||
query_override=query,
|
||||
)
|
||||
|
||||
body = new_message_request.dict()
|
||||
body["user"] = None
|
||||
try:
|
||||
response_json = requests.post(url, headers=headers, json=body).json()
|
||||
context_data_list = response_json.get("contexts", {}).get("contexts", [])
|
||||
response_json = requests.post(url, headers=GENERAL_HEADERS, json=body).json()
|
||||
simple_search_docs = response_json.get("simple_search_docs", [])
|
||||
answer = response_json.get("answer", "")
|
||||
except Exception as e:
|
||||
print("Failed to answer the questions, trying again")
|
||||
print(f"error: {str(e)}")
|
||||
print("Failed to answer the questions:")
|
||||
print(f"\t {str(e)}")
|
||||
print("trying again")
|
||||
raise e
|
||||
|
||||
return context_data_list, answer
|
||||
return simple_search_docs, answer
|
||||
|
||||
|
||||
@retry(tries=10, delay=10)
|
||||
def check_if_query_ready(run_suffix: str) -> bool:
|
||||
url = _api_url_builder(run_suffix, "/manage/admin/connector/indexing-status/")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
indexing_status_dict = requests.get(url, headers=headers).json()
|
||||
try:
|
||||
indexing_status_dict = requests.get(url, headers=GENERAL_HEADERS).json()
|
||||
except Exception as e:
|
||||
print("Failed to check indexing status, API server is likely starting up:")
|
||||
print(f"\t {str(e)}")
|
||||
print("trying again")
|
||||
raise e
|
||||
|
||||
ongoing_index_attempts = False
|
||||
doc_count = 0
|
||||
@@ -93,17 +112,13 @@ def check_if_query_ready(run_suffix: str) -> bool:
|
||||
|
||||
def run_cc_once(run_suffix: str, connector_id: int, credential_id: int) -> None:
|
||||
url = _api_url_builder(run_suffix, "/manage/admin/connector/run-once/")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
body = {
|
||||
"connector_id": connector_id,
|
||||
"credential_ids": [credential_id],
|
||||
"from_beginning": True,
|
||||
}
|
||||
print("body:", body)
|
||||
response = requests.post(url, headers=headers, json=body)
|
||||
response = requests.post(url, headers=GENERAL_HEADERS, json=body)
|
||||
if response.status_code == 200:
|
||||
print("Connector created successfully:", response.json())
|
||||
else:
|
||||
@@ -115,13 +130,10 @@ def create_cc_pair(run_suffix: str, connector_id: int, credential_id: int) -> No
|
||||
url = _api_url_builder(
|
||||
run_suffix, f"/manage/connector/{connector_id}/credential/{credential_id}"
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
body = {"name": "zip_folder_contents", "is_public": True}
|
||||
print("body:", body)
|
||||
response = requests.put(url, headers=headers, json=body)
|
||||
response = requests.put(url, headers=GENERAL_HEADERS, json=body)
|
||||
if response.status_code == 200:
|
||||
print("Connector created successfully:", response.json())
|
||||
else:
|
||||
@@ -131,14 +143,12 @@ def create_cc_pair(run_suffix: str, connector_id: int, credential_id: int) -> No
|
||||
|
||||
def _get_existing_connector_names(run_suffix: str) -> list[str]:
|
||||
url = _api_url_builder(run_suffix, "/manage/connector")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
body = {
|
||||
"credential_json": {},
|
||||
"admin_public": True,
|
||||
}
|
||||
response = requests.get(url, headers=headers, json=body)
|
||||
response = requests.get(url, headers=GENERAL_HEADERS, json=body)
|
||||
if response.status_code == 200:
|
||||
connectors = response.json()
|
||||
return [connector["name"] for connector in connectors]
|
||||
@@ -148,9 +158,6 @@ def _get_existing_connector_names(run_suffix: str) -> list[str]:
|
||||
|
||||
def create_connector(run_suffix: str, file_paths: list[str]) -> int:
|
||||
url = _api_url_builder(run_suffix, "/manage/admin/connector")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
connector_name = base_connector_name = "search_eval_connector"
|
||||
existing_connector_names = _get_existing_connector_names(run_suffix)
|
||||
|
||||
@@ -171,7 +178,7 @@ def create_connector(run_suffix: str, file_paths: list[str]) -> int:
|
||||
|
||||
body = connector.dict()
|
||||
print("body:", body)
|
||||
response = requests.post(url, headers=headers, json=body)
|
||||
response = requests.post(url, headers=GENERAL_HEADERS, json=body)
|
||||
if response.status_code == 200:
|
||||
print("Connector created successfully:", response.json())
|
||||
return response.json()["id"]
|
||||
@@ -181,14 +188,11 @@ def create_connector(run_suffix: str, file_paths: list[str]) -> int:
|
||||
|
||||
def create_credential(run_suffix: str) -> int:
|
||||
url = _api_url_builder(run_suffix, "/manage/credential")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {
|
||||
"credential_json": {},
|
||||
"admin_public": True,
|
||||
}
|
||||
response = requests.post(url, headers=headers, json=body)
|
||||
response = requests.post(url, headers=GENERAL_HEADERS, json=body)
|
||||
if response.status_code == 200:
|
||||
print("credential created successfully:", response.json())
|
||||
return response.json()["id"]
|
||||
|
||||
@@ -3,9 +3,12 @@ import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from threading import Thread
|
||||
from typing import IO
|
||||
|
||||
import yaml
|
||||
from retry import retry
|
||||
|
||||
|
||||
@@ -57,17 +60,44 @@ def get_current_commit_sha() -> str:
|
||||
return sha
|
||||
|
||||
|
||||
def switch_to_branch(branch: str) -> None:
|
||||
print(f"Switching to branch: {branch}...")
|
||||
_run_command(f"git checkout {branch}")
|
||||
_run_command("git pull")
|
||||
print(f"Successfully switched to branch: {branch}")
|
||||
def switch_to_commit(commit_sha: str) -> None:
|
||||
print(f"Switching to commit: {commit_sha}...")
|
||||
_run_command(f"git checkout {commit_sha}")
|
||||
print(f"Successfully switched to commit: {commit_sha}")
|
||||
print("Repository updated successfully.")
|
||||
|
||||
|
||||
def manage_data_directories(suffix: str, base_path: str, use_cloud_gpu: bool) -> str:
|
||||
def get_docker_container_env_vars(suffix: str) -> dict:
|
||||
"""
|
||||
Retrieves environment variables from "background" and "api_server" Docker containers.
|
||||
"""
|
||||
print(f"Getting environment variables for containers with suffix: {suffix}")
|
||||
|
||||
combined_env_vars = {}
|
||||
for container_type in ["background", "api_server"]:
|
||||
container_name = _run_command(
|
||||
f"docker ps -a --format '{{{{.Names}}}}' | awk '/{container_type}/ && /{suffix}/'"
|
||||
)[0].strip()
|
||||
if not container_name:
|
||||
raise RuntimeError(
|
||||
f"No {container_type} container found with suffix: {suffix}"
|
||||
)
|
||||
|
||||
env_vars_json = _run_command(
|
||||
f"docker inspect --format='{{{{json .Config.Env}}}}' {container_name}"
|
||||
)[0]
|
||||
env_vars_list = json.loads(env_vars_json.strip())
|
||||
|
||||
for env_var in env_vars_list:
|
||||
key, value = env_var.split("=", 1)
|
||||
combined_env_vars[key] = value
|
||||
|
||||
return combined_env_vars
|
||||
|
||||
|
||||
def manage_data_directories(suffix: str, base_path: str, use_cloud_gpu: bool) -> None:
|
||||
# Use the user's home directory as the base path
|
||||
target_path = os.path.join(os.path.expanduser(base_path), f"test{suffix}")
|
||||
target_path = os.path.join(os.path.expanduser(base_path), suffix)
|
||||
directories = {
|
||||
"DANSWER_POSTGRES_DATA_DIR": os.path.join(target_path, "postgres/"),
|
||||
"DANSWER_VESPA_DATA_DIR": os.path.join(target_path, "vespa/"),
|
||||
@@ -85,9 +115,8 @@ def manage_data_directories(suffix: str, base_path: str, use_cloud_gpu: bool) ->
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
os.environ[env_var] = directory
|
||||
print(f"Set {env_var} to: {directory}")
|
||||
relari_output_path = os.path.join(target_path, "relari_output/")
|
||||
os.makedirs(relari_output_path, exist_ok=True)
|
||||
return relari_output_path
|
||||
results_output_path = os.path.join(target_path, "evaluations_output/")
|
||||
os.makedirs(results_output_path, exist_ok=True)
|
||||
|
||||
|
||||
def set_env_variables(
|
||||
@@ -115,26 +144,30 @@ def _is_port_in_use(port: int) -> bool:
|
||||
|
||||
|
||||
def start_docker_compose(
|
||||
run_suffix: str, launch_web_ui: bool, use_cloud_gpu: bool
|
||||
run_suffix: str, launch_web_ui: bool, use_cloud_gpu: bool, only_state: bool = False
|
||||
) -> None:
|
||||
print("Starting Docker Compose...")
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
os.chdir("../../../../deployment/docker_compose/")
|
||||
command = f"docker compose -f docker-compose.search-testing.yml -p danswer-stack{run_suffix} up -d"
|
||||
command = f"docker compose -f docker-compose.search-testing.yml -p danswer-stack-{run_suffix} up -d"
|
||||
command += " --build"
|
||||
command += " --force-recreate"
|
||||
if use_cloud_gpu:
|
||||
command += " --scale indexing_model_server=0"
|
||||
command += " --scale inference_model_server=0"
|
||||
if launch_web_ui:
|
||||
web_ui_port = 3000
|
||||
while _is_port_in_use(web_ui_port):
|
||||
web_ui_port += 1
|
||||
print(f"UI will be launched at http://localhost:{web_ui_port}")
|
||||
os.environ["NGINX_PORT"] = str(web_ui_port)
|
||||
|
||||
if only_state:
|
||||
command += " index relational_db"
|
||||
else:
|
||||
command += " --scale web_server=0"
|
||||
command += " --scale nginx=0"
|
||||
if use_cloud_gpu:
|
||||
command += " --scale indexing_model_server=0"
|
||||
command += " --scale inference_model_server=0"
|
||||
if launch_web_ui:
|
||||
web_ui_port = 3000
|
||||
while _is_port_in_use(web_ui_port):
|
||||
web_ui_port += 1
|
||||
print(f"UI will be launched at http://localhost:{web_ui_port}")
|
||||
os.environ["NGINX_PORT"] = str(web_ui_port)
|
||||
else:
|
||||
command += " --scale web_server=0"
|
||||
command += " --scale nginx=0"
|
||||
|
||||
print("Docker Command:\n", command)
|
||||
|
||||
@@ -150,7 +183,8 @@ def cleanup_docker(run_suffix: str) -> None:
|
||||
stdout, _ = _run_command("docker ps -a --format '{{json .}}'")
|
||||
|
||||
containers = [json.loads(line) for line in stdout.splitlines()]
|
||||
|
||||
if not run_suffix:
|
||||
run_suffix = datetime.now().strftime("-%Y")
|
||||
project_name = f"danswer-stack{run_suffix}"
|
||||
containers_to_delete = [
|
||||
c for c in containers if c["Names"].startswith(project_name)
|
||||
@@ -247,3 +281,71 @@ def get_api_server_host_port(suffix: str) -> str:
|
||||
f"No port found containing: {client_port} for container: {container_name} and suffix: {suffix}"
|
||||
)
|
||||
return matching_ports[0]
|
||||
|
||||
|
||||
# Added function to check Vespa container health status
|
||||
def is_vespa_container_healthy(suffix: str) -> bool:
|
||||
print(f"Checking health status of Vespa container for suffix: {suffix}")
|
||||
|
||||
# Find the Vespa container
|
||||
stdout, _ = _run_command(
|
||||
f"docker ps -a --format '{{{{.Names}}}}' | awk /vespa/ && /{suffix}/"
|
||||
)
|
||||
container_name = stdout.strip()
|
||||
|
||||
if not container_name:
|
||||
print(f"No Vespa container found with suffix: {suffix}")
|
||||
return False
|
||||
|
||||
# Get the health status
|
||||
stdout, _ = _run_command(
|
||||
f"docker inspect --format='{{{{.State.Health.Status}}}}' {container_name}"
|
||||
)
|
||||
health_status = stdout.strip()
|
||||
|
||||
is_healthy = health_status.lower() == "healthy"
|
||||
print(f"Vespa container '{container_name}' health status: {health_status}")
|
||||
|
||||
return is_healthy
|
||||
|
||||
|
||||
# Added function to restart Vespa container
|
||||
def restart_vespa_container(suffix: str) -> None:
|
||||
print(f"Restarting Vespa container for suffix: {suffix}")
|
||||
|
||||
# Find the Vespa container
|
||||
stdout, _ = _run_command(
|
||||
f"docker ps -a --format '{{{{.Names}}}}' | awk /vespa/ && /{suffix}/"
|
||||
)
|
||||
container_name = stdout.strip()
|
||||
|
||||
if not container_name:
|
||||
raise RuntimeError(f"No Vespa container found with suffix: {suffix}")
|
||||
|
||||
# Restart the container
|
||||
_run_command(f"docker restart {container_name}")
|
||||
|
||||
print(f"Vespa container '{container_name}' has begun restarting")
|
||||
|
||||
time_to_wait = 5
|
||||
while not is_vespa_container_healthy(suffix):
|
||||
print(f"Waiting {time_to_wait} seconds for vespa container to restart")
|
||||
time.sleep(5)
|
||||
|
||||
print(f"Vespa container '{container_name}' has been restarted")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""
|
||||
Running this just cleans up the docker environment for the container indicated by existing_test_suffix
|
||||
If no existing_test_suffix is indicated, will just clean up all danswer docker containers/volumes/networks
|
||||
Note: vespa/postgres mounts are not deleted
|
||||
"""
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(current_dir, "search_test_config.yaml")
|
||||
with open(config_path, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
if not isinstance(config, dict):
|
||||
raise TypeError("config must be a dictionary")
|
||||
cleanup_docker(config["existing_test_suffix"])
|
||||
|
||||
@@ -8,7 +8,7 @@ from tests.regression.answer_quality.cli_utils import cleanup_docker
|
||||
from tests.regression.answer_quality.cli_utils import manage_data_directories
|
||||
from tests.regression.answer_quality.cli_utils import set_env_variables
|
||||
from tests.regression.answer_quality.cli_utils import start_docker_compose
|
||||
from tests.regression.answer_quality.cli_utils import switch_to_branch
|
||||
from tests.regression.answer_quality.cli_utils import switch_to_commit
|
||||
from tests.regression.answer_quality.file_uploader import upload_test_files
|
||||
from tests.regression.answer_quality.run_qa import run_qa_test_and_save_results
|
||||
|
||||
@@ -26,7 +26,7 @@ def main() -> None:
|
||||
run_suffix = config.existing_test_suffix
|
||||
print("launching danswer with existing data suffix:", run_suffix)
|
||||
else:
|
||||
run_suffix = datetime.now().strftime("_%Y%m%d_%H%M%S")
|
||||
run_suffix = datetime.now().strftime("-%Y%m%d-%H%M%S")
|
||||
print("run_suffix:", run_suffix)
|
||||
|
||||
set_env_variables(
|
||||
@@ -35,20 +35,18 @@ def main() -> None:
|
||||
config.use_cloud_gpu,
|
||||
config.llm,
|
||||
)
|
||||
relari_output_folder_path = manage_data_directories(
|
||||
run_suffix, config.output_folder, config.use_cloud_gpu
|
||||
manage_data_directories(run_suffix, config.output_folder, config.use_cloud_gpu)
|
||||
if config.commit_sha:
|
||||
switch_to_commit(config.commit_sha)
|
||||
|
||||
start_docker_compose(
|
||||
run_suffix, config.launch_web_ui, config.use_cloud_gpu, config.only_state
|
||||
)
|
||||
if config.branch:
|
||||
switch_to_branch(config.branch)
|
||||
|
||||
start_docker_compose(run_suffix, config.launch_web_ui, config.use_cloud_gpu)
|
||||
|
||||
if not config.existing_test_suffix:
|
||||
if not config.existing_test_suffix and not config.only_state:
|
||||
upload_test_files(config.zipped_documents_file, run_suffix)
|
||||
|
||||
run_qa_test_and_save_results(
|
||||
config.questions_file, relari_output_folder_path, run_suffix, config.limit
|
||||
)
|
||||
run_qa_test_and_save_results(run_suffix)
|
||||
|
||||
if config.clean_up_docker_containers:
|
||||
cleanup_docker(run_suffix)
|
||||
|
||||
@@ -1,55 +1,35 @@
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
|
||||
from tests.regression.answer_quality.api_utils import check_if_query_ready
|
||||
from tests.regression.answer_quality.api_utils import get_answer_from_query
|
||||
from tests.regression.answer_quality.cli_utils import get_current_commit_sha
|
||||
from tests.regression.answer_quality.cli_utils import get_docker_container_env_vars
|
||||
|
||||
RESULTS_FILENAME = "results.jsonl"
|
||||
METADATA_FILENAME = "metadata.yaml"
|
||||
|
||||
|
||||
def _process_and_write_query_results(
|
||||
samples: list[dict], run_suffix: str, output_file_path: str
|
||||
) -> None:
|
||||
while not check_if_query_ready(run_suffix):
|
||||
time.sleep(5)
|
||||
|
||||
count = 0
|
||||
with open(output_file_path, "w", encoding="utf-8") as file:
|
||||
for sample in samples:
|
||||
print(f"On question number {count}")
|
||||
query = sample["question"]
|
||||
print(f"query: {query}")
|
||||
context_data_list, answer = get_answer_from_query(
|
||||
query=query,
|
||||
run_suffix=run_suffix,
|
||||
)
|
||||
|
||||
print(f"answer: {answer[:50]}...")
|
||||
if not context_data_list:
|
||||
print("No context found")
|
||||
else:
|
||||
print(f"{len(context_data_list)} context docs found")
|
||||
print("\n")
|
||||
|
||||
output = {
|
||||
"question_data": sample,
|
||||
"answer": answer,
|
||||
"context_data_list": context_data_list,
|
||||
}
|
||||
|
||||
file.write(json.dumps(output) + "\n")
|
||||
def _populate_results_file(output_folder_path: str, all_qa_output: list[dict]) -> None:
|
||||
output_file_path = os.path.join(output_folder_path, RESULTS_FILENAME)
|
||||
with open(output_file_path, "a", encoding="utf-8") as file:
|
||||
for qa_output in all_qa_output:
|
||||
file.write(json.dumps(qa_output) + "\n")
|
||||
file.flush()
|
||||
count += 1
|
||||
|
||||
|
||||
def _write_metadata_file(run_suffix: str, metadata_file_path: str) -> None:
|
||||
metadata = {"commit_sha": get_current_commit_sha(), "run_suffix": run_suffix}
|
||||
def _update_metadata_file(test_output_folder: str, invalid_answer_count: int) -> None:
|
||||
metadata_path = os.path.join(test_output_folder, METADATA_FILENAME)
|
||||
with open(metadata_path, "r", encoding="utf-8") as file:
|
||||
metadata = yaml.safe_load(file)
|
||||
|
||||
print("saving metadata to:", metadata_file_path)
|
||||
with open(metadata_file_path, "w", encoding="utf-8") as yaml_file:
|
||||
metadata["number_of_failed_questions"] = invalid_answer_count
|
||||
with open(metadata_path, "w", encoding="utf-8") as yaml_file:
|
||||
yaml.dump(metadata, yaml_file)
|
||||
|
||||
|
||||
@@ -62,60 +42,155 @@ def _read_questions_jsonl(questions_file_path: str) -> list[dict]:
|
||||
return questions
|
||||
|
||||
|
||||
def run_qa_test_and_save_results(
|
||||
questions_file_path: str,
|
||||
results_folder_path: str,
|
||||
run_suffix: str,
|
||||
limit: int | None = None,
|
||||
) -> None:
|
||||
results_file = "run_results.jsonl"
|
||||
metadata_file = "run_metadata.yaml"
|
||||
samples = _read_questions_jsonl(questions_file_path)
|
||||
|
||||
if limit is not None:
|
||||
samples = samples[:limit]
|
||||
def _get_test_output_folder(config: dict) -> str:
|
||||
base_output_folder = os.path.expanduser(config["output_folder"])
|
||||
if config["run_suffix"]:
|
||||
base_output_folder = os.path.join(
|
||||
base_output_folder, config["run_suffix"], "evaluations_output"
|
||||
)
|
||||
else:
|
||||
base_output_folder = os.path.join(base_output_folder, "no_defined_suffix")
|
||||
|
||||
counter = 1
|
||||
output_file_path = os.path.join(results_folder_path, results_file)
|
||||
metadata_file_path = os.path.join(results_folder_path, metadata_file)
|
||||
while os.path.exists(output_file_path):
|
||||
output_file_path = os.path.join(
|
||||
results_folder_path,
|
||||
results_file.replace("run_results", f"run_results_{counter}"),
|
||||
)
|
||||
metadata_file_path = os.path.join(
|
||||
results_folder_path,
|
||||
metadata_file.replace("run_metadata", f"run_metadata_{counter}"),
|
||||
output_folder_path = os.path.join(base_output_folder, "run_1")
|
||||
while os.path.exists(output_folder_path):
|
||||
output_folder_path = os.path.join(
|
||||
output_folder_path.replace(f"run_{counter-1}", f"run_{counter}"),
|
||||
)
|
||||
counter += 1
|
||||
|
||||
print("saving question results to:", output_file_path)
|
||||
_write_metadata_file(run_suffix, metadata_file_path)
|
||||
_process_and_write_query_results(
|
||||
samples=samples, run_suffix=run_suffix, output_file_path=output_file_path
|
||||
os.makedirs(output_folder_path, exist_ok=True)
|
||||
|
||||
return output_folder_path
|
||||
|
||||
|
||||
def _initialize_files(config: dict) -> tuple[str, list[dict]]:
|
||||
test_output_folder = _get_test_output_folder(config)
|
||||
|
||||
questions_file_path = config["questions_file"]
|
||||
|
||||
questions = _read_questions_jsonl(questions_file_path)
|
||||
|
||||
metadata = {
|
||||
"commit_sha": get_current_commit_sha(),
|
||||
"run_suffix": config["run_suffix"],
|
||||
"test_config": config,
|
||||
"number_of_questions_in_dataset": len(questions),
|
||||
}
|
||||
|
||||
env_vars = get_docker_container_env_vars(config["run_suffix"])
|
||||
if env_vars["ENV_SEED_CONFIGURATION"]:
|
||||
del env_vars["ENV_SEED_CONFIGURATION"]
|
||||
if env_vars["GPG_KEY"]:
|
||||
del env_vars["GPG_KEY"]
|
||||
if metadata["test_config"]["llm"]["api_key"]:
|
||||
del metadata["test_config"]["llm"]["api_key"]
|
||||
metadata.update(env_vars)
|
||||
metadata_path = os.path.join(test_output_folder, METADATA_FILENAME)
|
||||
print("saving metadata to:", metadata_path)
|
||||
with open(metadata_path, "w", encoding="utf-8") as yaml_file:
|
||||
yaml.dump(metadata, yaml_file)
|
||||
|
||||
copied_questions_file_path = os.path.join(
|
||||
test_output_folder, os.path.basename(questions_file_path)
|
||||
)
|
||||
shutil.copy2(questions_file_path, copied_questions_file_path)
|
||||
|
||||
zipped_files_path = config["zipped_documents_file"]
|
||||
copied_zipped_documents_path = os.path.join(
|
||||
test_output_folder, os.path.basename(zipped_files_path)
|
||||
)
|
||||
shutil.copy2(zipped_files_path, copied_zipped_documents_path)
|
||||
|
||||
zipped_files_folder = os.path.dirname(zipped_files_path)
|
||||
jsonl_file_path = os.path.join(zipped_files_folder, "target_docs.jsonl")
|
||||
if os.path.exists(jsonl_file_path):
|
||||
copied_jsonl_path = os.path.join(test_output_folder, "target_docs.jsonl")
|
||||
shutil.copy2(jsonl_file_path, copied_jsonl_path)
|
||||
|
||||
return test_output_folder, questions
|
||||
|
||||
|
||||
def _process_question(question_data: dict, config: dict, question_number: int) -> dict:
|
||||
print(f"On question number {question_number}")
|
||||
|
||||
query = question_data["question"]
|
||||
print(f"query: {query}")
|
||||
context_data_list, answer = get_answer_from_query(
|
||||
query=query,
|
||||
run_suffix=config["run_suffix"],
|
||||
)
|
||||
|
||||
if not context_data_list:
|
||||
print("No answer or context found")
|
||||
else:
|
||||
print(f"answer: {answer[:50]}...")
|
||||
print(f"{len(context_data_list)} context docs found")
|
||||
print("\n")
|
||||
|
||||
def main() -> None:
|
||||
output = {
|
||||
"question_data": question_data,
|
||||
"answer": answer,
|
||||
"context_data_list": context_data_list,
|
||||
}
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _process_and_write_query_results(config: dict) -> None:
|
||||
start_time = time.time()
|
||||
test_output_folder, questions = _initialize_files(config)
|
||||
print("saving test results to folder:", test_output_folder)
|
||||
|
||||
while not check_if_query_ready(config["run_suffix"]):
|
||||
time.sleep(5)
|
||||
|
||||
if config["limit"] is not None:
|
||||
questions = questions[: config["limit"]]
|
||||
|
||||
with multiprocessing.Pool(processes=multiprocessing.cpu_count() * 2) as pool:
|
||||
results = pool.starmap(
|
||||
_process_question, [(q, config, i + 1) for i, q in enumerate(questions)]
|
||||
)
|
||||
|
||||
_populate_results_file(test_output_folder, results)
|
||||
|
||||
invalid_answer_count = 0
|
||||
for result in results:
|
||||
if not result.get("answer"):
|
||||
invalid_answer_count += 1
|
||||
|
||||
if not result.get("context_data_list"):
|
||||
raise RuntimeError("Search failed, this is a critical failure!")
|
||||
|
||||
_update_metadata_file(test_output_folder, invalid_answer_count)
|
||||
|
||||
if invalid_answer_count:
|
||||
print(f"Warning: {invalid_answer_count} questions failed!")
|
||||
print("Suggest restarting the vespa container and rerunning")
|
||||
|
||||
time_to_finish = time.time() - start_time
|
||||
minutes, seconds = divmod(int(time_to_finish), 60)
|
||||
print(
|
||||
f"Took {minutes:02d}:{seconds:02d} to ask and answer {len(results)} questions"
|
||||
)
|
||||
print("saved test results to folder:", test_output_folder)
|
||||
|
||||
|
||||
def run_qa_test_and_save_results(run_suffix: str = "") -> None:
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(current_dir, "search_test_config.yaml")
|
||||
with open(config_path, "r") as file:
|
||||
config = SimpleNamespace(**yaml.safe_load(file))
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
current_output_folder = os.path.expanduser(config.output_folder)
|
||||
if config.existing_test_suffix:
|
||||
current_output_folder = os.path.join(
|
||||
current_output_folder, "test" + config.existing_test_suffix, "relari_output"
|
||||
)
|
||||
else:
|
||||
current_output_folder = os.path.join(current_output_folder, "no_defined_suffix")
|
||||
if not isinstance(config, dict):
|
||||
raise TypeError("config must be a dictionary")
|
||||
|
||||
run_qa_test_and_save_results(
|
||||
config.questions_file,
|
||||
current_output_folder,
|
||||
config.existing_test_suffix,
|
||||
config.limit,
|
||||
)
|
||||
if not run_suffix:
|
||||
run_suffix = config["existing_test_suffix"]
|
||||
|
||||
config["run_suffix"] = run_suffix
|
||||
_process_and_write_query_results(config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -123,4 +198,4 @@ if __name__ == "__main__":
|
||||
To run a different set of questions, update the questions_file in search_test_config.yaml
|
||||
If there is more than one instance of Danswer running, specify the suffix in search_test_config.yaml
|
||||
"""
|
||||
main()
|
||||
run_qa_test_and_save_results()
|
||||
|
||||
@@ -10,8 +10,8 @@ zipped_documents_file: "~/sampledocs.zip"
|
||||
# Path to the YAML file containing sample questions
|
||||
questions_file: "~/sample_questions.yaml"
|
||||
|
||||
# Git branch to use (null means use current branch as is)
|
||||
branch: null
|
||||
# Git commit SHA to use (null means use current code as is)
|
||||
commit_sha: null
|
||||
|
||||
# Whether to remove Docker containers after the test
|
||||
clean_up_docker_containers: true
|
||||
@@ -19,6 +19,9 @@ clean_up_docker_containers: true
|
||||
# Whether to launch a web UI for the test
|
||||
launch_web_ui: false
|
||||
|
||||
# Whether to only run Vespa and Postgres
|
||||
only_state: false
|
||||
|
||||
# Whether to use a cloud GPU for processing
|
||||
use_cloud_gpu: false
|
||||
|
||||
@@ -28,7 +31,8 @@ model_server_ip: "PUT_PUBLIC_CLOUD_IP_HERE"
|
||||
# Port of the model server (placeholder)
|
||||
model_server_port: "PUT_PUBLIC_CLOUD_PORT_HERE"
|
||||
|
||||
# Suffix for existing test results (empty string means no suffix)
|
||||
# Suffix for existing test results (E.g. -1234-5678)
|
||||
# empty string means no suffix
|
||||
existing_test_suffix: ""
|
||||
|
||||
# Limit on number of tests to run (null means no limit)
|
||||
|
||||
@@ -1 +1 @@
|
||||
f1f2 1 1718910083.03085 wikipedia:en
|
||||
f1f2 2 1721064549.902656 wikipedia:en
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
environment:
|
||||
# Auth Settings
|
||||
- AUTH_TYPE=${AUTH_TYPE:-disabled}
|
||||
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-86400}
|
||||
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-}
|
||||
- ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-}
|
||||
- VALID_EMAIL_DOMAINS=${VALID_EMAIL_DOMAINS:-}
|
||||
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||
@@ -51,6 +51,7 @@ services:
|
||||
- DISABLE_LITELLM_STREAMING=${DISABLE_LITELLM_STREAMING:-}
|
||||
- LITELLM_EXTRA_HEADERS=${LITELLM_EXTRA_HEADERS:-}
|
||||
- BING_API_KEY=${BING_API_KEY:-}
|
||||
- DISABLE_AGENTIC_SEARCH=${DISABLE_AGENTIC_SEARCH:-}
|
||||
# if set, allows for the use of the token budget system
|
||||
- TOKEN_BUDGET_GLOBALLY_ENABLED=${TOKEN_BUDGET_GLOBALLY_ENABLED:-}
|
||||
# Enables the use of bedrock models
|
||||
@@ -230,6 +231,7 @@ services:
|
||||
- INTERNAL_URL=http://api_server:8080
|
||||
- WEB_DOMAIN=${WEB_DOMAIN:-}
|
||||
- THEME_IS_DARK=${THEME_IS_DARK:-}
|
||||
- DISABLE_AGENTIC_SEARCH=${DISABLE_AGENTIC_SEARCH:-}
|
||||
|
||||
# Enterprise Edition only
|
||||
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false}
|
||||
@@ -282,7 +284,7 @@ services:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
# Not necessary, this is just to reduce download time during startup
|
||||
- model_cache_huggingface:/root/.cache/huggingface/
|
||||
- indexing_huggingface_model_cache:/root/.cache/huggingface/
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -353,3 +355,4 @@ volumes:
|
||||
vespa_volume:
|
||||
# Created by the container itself
|
||||
model_cache_huggingface:
|
||||
indexing_huggingface_model_cache:
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
environment:
|
||||
# Auth Settings
|
||||
- AUTH_TYPE=${AUTH_TYPE:-disabled}
|
||||
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-86400}
|
||||
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-}
|
||||
- ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-}
|
||||
- VALID_EMAIL_DOMAINS=${VALID_EMAIL_DOMAINS:-}
|
||||
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||
@@ -289,7 +289,7 @@ services:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
# Not necessary, this is just to reduce download time during startup
|
||||
- model_cache_huggingface:/root/.cache/huggingface/
|
||||
- indexing_huggingface_model_cache:/root/.cache/huggingface/
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -360,3 +360,4 @@ volumes:
|
||||
vespa_volume:
|
||||
# Created by the container itself
|
||||
model_cache_huggingface:
|
||||
indexing_huggingface_model_cache:
|
||||
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
# Not necessary, this is just to reduce download time during startup
|
||||
- model_cache_huggingface:/root/.cache/huggingface/
|
||||
- indexing_huggingface_model_cache:/root/.cache/huggingface/
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -207,3 +207,4 @@ volumes:
|
||||
vespa_volume:
|
||||
# Created by the container itself
|
||||
model_cache_huggingface:
|
||||
indexing_huggingface_model_cache:
|
||||
|
||||
@@ -146,7 +146,7 @@ services:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
# Not necessary, this is just to reduce download time during startup
|
||||
- model_cache_huggingface:/root/.cache/huggingface/
|
||||
- indexing_huggingface_model_cache:/root/.cache/huggingface/
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -225,3 +225,4 @@ volumes:
|
||||
vespa_volume:
|
||||
# Created by the container itself
|
||||
model_cache_huggingface:
|
||||
indexing_huggingface_model_cache:
|
||||
|
||||
@@ -57,8 +57,8 @@ SECRET=
|
||||
#SAML_CONF_DIR=
|
||||
|
||||
|
||||
# How long before user needs to reauthenticate, default to 1 day. (cookie expiration time)
|
||||
SESSION_EXPIRE_TIME_SECONDS=86400
|
||||
# How long before user needs to reauthenticate, default to 7 days. (cookie expiration time)
|
||||
SESSION_EXPIRE_TIME_SECONDS=604800
|
||||
|
||||
|
||||
# Use the below to specify a list of allowed user domains, only checked if user Auth is turned on
|
||||
|
||||
3
examples/widget/.eslintrc.json
Normal file
3
examples/widget/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
36
examples/widget/.gitignore
vendored
Normal file
36
examples/widget/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
19
examples/widget/README.md
Normal file
19
examples/widget/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
This is a code example for how you can use Danswer's APIs to build a chat bot widget for a website! The main code to look at can be found in `src/app/widget/Widget.tsx`.
|
||||
|
||||
If you want to get fancier, then take a peek at the Chat implementation within Danswer itself [here](https://github.com/danswer-ai/danswer/blob/main/web/src/app/chat/ChatPage.tsx#L82).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, install the requirements:
|
||||
|
||||
```bash
|
||||
npm i
|
||||
```
|
||||
|
||||
Then run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
4
examples/widget/next.config.mjs
Normal file
4
examples/widget/next.config.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
5933
examples/widget/package-lock.json
generated
Normal file
5933
examples/widget/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
examples/widget/package.json
Normal file
28
examples/widget/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "widget",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-markdown": "^8.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
examples/widget/postcss.config.mjs
Normal file
8
examples/widget/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
3
examples/widget/src/app/globals.css
Normal file
3
examples/widget/src/app/globals.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
23
examples/widget/src/app/layout.tsx
Normal file
23
examples/widget/src/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Example Danswer Widget",
|
||||
description: "Example Danswer Widget",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
9
examples/widget/src/app/page.tsx
Normal file
9
examples/widget/src/app/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ChatWidget } from "./widget/Widget";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<ChatWidget />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
344
examples/widget/src/app/widget/Widget.tsx
Normal file
344
examples/widget/src/app/widget/Widget.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_API_KEY || "";
|
||||
|
||||
type NonEmptyObject = { [k: string]: any };
|
||||
|
||||
const processSingleChunk = <T extends NonEmptyObject>(
|
||||
chunk: string,
|
||||
currPartialChunk: string | null,
|
||||
): [T | null, string | null] => {
|
||||
const completeChunk = (currPartialChunk || "") + chunk;
|
||||
try {
|
||||
// every complete chunk should be valid JSON
|
||||
const chunkJson = JSON.parse(completeChunk);
|
||||
return [chunkJson, null];
|
||||
} catch (err) {
|
||||
// if it's not valid JSON, then it's probably an incomplete chunk
|
||||
return [null, completeChunk];
|
||||
}
|
||||
};
|
||||
|
||||
const processRawChunkString = <T extends NonEmptyObject>(
|
||||
rawChunkString: string,
|
||||
previousPartialChunk: string | null,
|
||||
): [T[], string | null] => {
|
||||
/* This is required because, in practice, we see that nginx does not send over
|
||||
each chunk one at a time even with buffering turned off. Instead,
|
||||
chunks are sometimes in batches or are sometimes incomplete */
|
||||
if (!rawChunkString) {
|
||||
return [[], null];
|
||||
}
|
||||
const chunkSections = rawChunkString
|
||||
.split("\n")
|
||||
.filter((chunk) => chunk.length > 0);
|
||||
let parsedChunkSections: T[] = [];
|
||||
let currPartialChunk = previousPartialChunk;
|
||||
chunkSections.forEach((chunk) => {
|
||||
const [processedChunk, partialChunk] = processSingleChunk<T>(
|
||||
chunk,
|
||||
currPartialChunk,
|
||||
);
|
||||
if (processedChunk) {
|
||||
parsedChunkSections.push(processedChunk);
|
||||
currPartialChunk = null;
|
||||
} else {
|
||||
currPartialChunk = partialChunk;
|
||||
}
|
||||
});
|
||||
|
||||
return [parsedChunkSections, currPartialChunk];
|
||||
};
|
||||
|
||||
async function* handleStream<T extends NonEmptyObject>(
|
||||
streamingResponse: Response,
|
||||
): AsyncGenerator<T[], void, unknown> {
|
||||
const reader = streamingResponse.body?.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
let previousPartialChunk: string | null = null;
|
||||
while (true) {
|
||||
const rawChunk = await reader?.read();
|
||||
if (!rawChunk) {
|
||||
throw new Error("Unable to process chunk");
|
||||
}
|
||||
const { done, value } = rawChunk;
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const [completedChunks, partialChunk] = processRawChunkString<T>(
|
||||
decoder.decode(value, { stream: true }),
|
||||
previousPartialChunk,
|
||||
);
|
||||
if (!completedChunks.length && !partialChunk) {
|
||||
break;
|
||||
}
|
||||
previousPartialChunk = partialChunk as string | null;
|
||||
|
||||
yield await Promise.resolve(completedChunks);
|
||||
}
|
||||
}
|
||||
|
||||
async function* sendMessage({
|
||||
message,
|
||||
chatSessionId,
|
||||
parentMessageId,
|
||||
}: {
|
||||
message: string;
|
||||
chatSessionId?: number;
|
||||
parentMessageId?: number;
|
||||
}) {
|
||||
if (!chatSessionId || !parentMessageId) {
|
||||
// Create a new chat session if one doesn't exist
|
||||
const createSessionResponse = await fetch(
|
||||
`${API_URL}/chat/create-chat-session`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// or specify an assistant you have defined
|
||||
persona_id: 0,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!createSessionResponse.ok) {
|
||||
const errorJson = await createSessionResponse.json();
|
||||
const errorMsg = errorJson.message || errorJson.detail || "";
|
||||
throw Error(`Failed to create chat session - ${errorMsg}`);
|
||||
}
|
||||
|
||||
const sessionData = await createSessionResponse.json();
|
||||
chatSessionId = sessionData.chat_session_id;
|
||||
}
|
||||
|
||||
const sendMessageResponse = await fetch(`${API_URL}/chat/send-message`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_session_id: chatSessionId,
|
||||
parent_message_id: parentMessageId,
|
||||
message: message,
|
||||
prompt_id: null,
|
||||
search_doc_ids: null,
|
||||
file_descriptors: [],
|
||||
// checkout https://github.com/danswer-ai/danswer/blob/main/backend/danswer/search/models.py#L105 for
|
||||
// all available options
|
||||
retrieval_options: {
|
||||
run_search: "always",
|
||||
filters: null,
|
||||
},
|
||||
query_override: null,
|
||||
}),
|
||||
});
|
||||
if (!sendMessageResponse.ok) {
|
||||
const errorJson = await sendMessageResponse.json();
|
||||
const errorMsg = errorJson.message || errorJson.detail || "";
|
||||
throw Error(`Failed to send message - ${errorMsg}`);
|
||||
}
|
||||
|
||||
yield* handleStream<NonEmptyObject>(sendMessageResponse);
|
||||
}
|
||||
|
||||
export const ChatWidget = () => {
|
||||
const [messages, setMessages] = useState<{ text: string; isUser: boolean }[]>(
|
||||
[],
|
||||
);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (inputText.trim()) {
|
||||
const initialPrevMessages = messages;
|
||||
setMessages([...initialPrevMessages, { text: inputText, isUser: true }]);
|
||||
setInputText("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const messageGenerator = sendMessage({
|
||||
message: inputText,
|
||||
chatSessionId: undefined,
|
||||
parentMessageId: undefined,
|
||||
});
|
||||
let fullResponse = "";
|
||||
|
||||
for await (const chunks of messageGenerator) {
|
||||
for (const chunk of chunks) {
|
||||
if ("answer_piece" in chunk) {
|
||||
fullResponse += chunk.answer_piece;
|
||||
setMessages([
|
||||
...initialPrevMessages,
|
||||
{ text: inputText, isUser: true },
|
||||
{ text: fullResponse, isUser: false },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
{ text: "An error occurred. Please try again.", isUser: false },
|
||||
]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
fixed
|
||||
bottom-4
|
||||
right-4
|
||||
z-50
|
||||
bg-white
|
||||
rounded-lg
|
||||
shadow-xl
|
||||
w-96
|
||||
h-[32rem]
|
||||
flex
|
||||
flex-col
|
||||
overflow-hidden
|
||||
transition-all
|
||||
duration-300
|
||||
ease-in-out
|
||||
"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
bg-gradient-to-r
|
||||
from-blue-600
|
||||
to-blue-800
|
||||
text-white
|
||||
p-4
|
||||
font-bold
|
||||
flex
|
||||
justify-between
|
||||
items-center
|
||||
"
|
||||
>
|
||||
<span>Chat Support</span>
|
||||
</div>
|
||||
<div
|
||||
className="
|
||||
flex-grow
|
||||
overflow-y-auto
|
||||
p-4
|
||||
space-y-4
|
||||
bg-gray-50
|
||||
border-b
|
||||
border-gray-200
|
||||
"
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`
|
||||
flex
|
||||
${message.isUser ? "justify-end" : "justify-start"}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
max-w-[75%]
|
||||
p-3
|
||||
rounded-lg
|
||||
${
|
||||
message.isUser
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-white text-black"
|
||||
}
|
||||
shadow
|
||||
`}
|
||||
>
|
||||
<ReactMarkdown>{message.text}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-pulse flex space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="
|
||||
p-4
|
||||
bg-white
|
||||
border-t
|
||||
border-gray-200
|
||||
"
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
className="
|
||||
w-full
|
||||
p-2
|
||||
pr-10
|
||||
border
|
||||
border-gray-300
|
||||
rounded-full
|
||||
focus:outline-none
|
||||
focus:ring-2
|
||||
focus:ring-blue-500
|
||||
focus:border-transparent
|
||||
"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="
|
||||
absolute
|
||||
right-2
|
||||
top-1/2
|
||||
transform
|
||||
-translate-y-1/2
|
||||
text-blue-500
|
||||
hover:text-blue-600
|
||||
focus:outline-none
|
||||
"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
examples/widget/tailwind.config.ts
Normal file
20
examples/widget/tailwind.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
26
examples/widget/tsconfig.json
Normal file
26
examples/widget/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -400,7 +400,7 @@ export function AssistantEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Form className="w-full">
|
||||
<div className="pb-6">
|
||||
<TextFormField
|
||||
name="name"
|
||||
@@ -550,7 +550,7 @@ export function AssistantEditor({
|
||||
|
||||
{searchToolEnabled() && (
|
||||
<CollapsibleSection prompt="Configure Search">
|
||||
<div className=" ">
|
||||
<div>
|
||||
{ccPairs.length > 0 && (
|
||||
<>
|
||||
<Label small>Document Sets</Label>
|
||||
|
||||
@@ -39,8 +39,10 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
`}
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
{" "}
|
||||
Great and also a
|
||||
{isCollapsed ? (
|
||||
<span className="collapse-toggle text-lg absolute left-0 top-0 text-sm flex items-center gap-x-3 cursor-pointer">
|
||||
<span className="collapse-toggle text-lg absolute left-0 top-0 text-sm flex items-center gap-x-3 cursor-pointer">
|
||||
<FiSettings className="pointer-events-none my-auto" size={16} />
|
||||
{prompt}{" "}
|
||||
</span>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function Page() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<BackButton />
|
||||
<AdminPageTitle
|
||||
title="Create a New Assistant"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Divider, Text, Title } from "@tremor/react";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { Persona } from "./interfaces";
|
||||
import { RobotIcon } from "@/components/icons/icons";
|
||||
import { AssistantsIcon, RobotIcon } from "@/components/icons/icons";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
|
||||
export default async function Page() {
|
||||
@@ -24,7 +24,7 @@ export default async function Page() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
<AdminPageTitle icon={<RobotIcon size={32} />} title="Assistants" />
|
||||
<AdminPageTitle icon={<AssistantsIcon size={32} />} title="Assistants" />
|
||||
|
||||
<Text className="mb-2">
|
||||
Assistants are a way to build custom search/question-answering
|
||||
|
||||
@@ -301,7 +301,7 @@ const Page = () => {
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<AdminPageTitle
|
||||
icon={<FiSlack size={32} />}
|
||||
icon={<SlackIcon size={32} />}
|
||||
title="Slack Bot Configuration"
|
||||
/>
|
||||
<InstantSSRAutoRefresh />
|
||||
|
||||
@@ -34,7 +34,7 @@ const DriveJsonUpload = ({
|
||||
<input
|
||||
className={
|
||||
"mr-3 text-sm text-gray-900 border border-gray-300 rounded-lg " +
|
||||
"cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none " +
|
||||
"cursor-pointer bg-background dark:text-gray-400 focus:outline-none " +
|
||||
"dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400"
|
||||
}
|
||||
type="file"
|
||||
|
||||
@@ -34,7 +34,7 @@ const DriveJsonUpload = ({
|
||||
<input
|
||||
className={
|
||||
"mr-3 text-sm text-gray-900 border border-gray-300 rounded-lg " +
|
||||
"cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none " +
|
||||
"cursor-pointer bg-background dark:text-gray-400 focus:outline-none " +
|
||||
"dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400"
|
||||
}
|
||||
type="file"
|
||||
|
||||
@@ -116,7 +116,13 @@ export default function CloudEmbeddingPage({
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 my-2 border-2 border-neutral-300 border-opacity-40 rounded-md rounded cursor-pointer
|
||||
${!provider.configured ? "opacity-80 hover:opacity-100" : enabled ? "bg-background-stronger" : "hover:bg-background-strong"}`}
|
||||
${
|
||||
!provider.configured
|
||||
? "opacity-80 hover:opacity-100"
|
||||
: enabled
|
||||
? "bg-background-stronger"
|
||||
: "hover:bg-background-strong"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (enabled) {
|
||||
setAlreadySelectedModel(model);
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface EmbeddingDetails {
|
||||
default_model_id?: number;
|
||||
name: string;
|
||||
}
|
||||
import { EmbeddingIcon, PackageIcon } from "@/components/icons/icons";
|
||||
|
||||
function Main() {
|
||||
const [openToggle, setOpenToggle] = useState(true);
|
||||
@@ -364,17 +365,25 @@ function Main() {
|
||||
monitor the progress of the re-indexing on this page.
|
||||
</Text>
|
||||
|
||||
<div className="mt-8 text-sm mr-auto mb-12 divide-x-2 flex ">
|
||||
<div className="mt-8 text-sm mr-auto mb-12 divide-x-2 flex">
|
||||
<button
|
||||
onClick={() => setOpenToggle(true)}
|
||||
className={` mx-2 p-2 font-bold ${openToggle ? "rounded bg-neutral-900 text-neutral-100 underline" : "hover:underline"}`}
|
||||
className={` mx-2 p-2 font-bold ${
|
||||
openToggle
|
||||
? "rounded bg-neutral-900 text-neutral-100 underline"
|
||||
: "hover:underline"
|
||||
}`}
|
||||
>
|
||||
Self-hosted
|
||||
</button>
|
||||
<div className="px-2 ">
|
||||
<button
|
||||
onClick={() => setOpenToggle(false)}
|
||||
className={`mx-2 p-2 font-bold ${!openToggle ? "rounded bg-neutral-900 text-neutral-100 underline" : " hover:underline"}`}
|
||||
className={`mx-2 p-2 font-bold ${
|
||||
!openToggle
|
||||
? "rounded bg-neutral-900 text-neutral-100 underline"
|
||||
: " hover:underline"
|
||||
}`}
|
||||
>
|
||||
Cloud-based
|
||||
</button>
|
||||
@@ -516,7 +525,7 @@ function Page() {
|
||||
<div className="mx-auto container">
|
||||
<AdminPageTitle
|
||||
title="Embedding"
|
||||
icon={<FiPackage size={32} className="my-auto" />}
|
||||
icon={<EmbeddingIcon size={32} className="my-auto" />}
|
||||
/>
|
||||
|
||||
<Main />
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { FiCpu } from "react-icons/fi";
|
||||
import { LLMConfiguration } from "./LLMConfiguration";
|
||||
import { CpuIcon } from "@/components/icons/icons";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
<AdminPageTitle
|
||||
title="LLM Setup"
|
||||
icon={<FiCpu size={32} className="my-auto" />}
|
||||
icon={<CpuIcon size={32} className="my-auto" />}
|
||||
/>
|
||||
|
||||
<LLMConfiguration />
|
||||
|
||||
@@ -2,13 +2,14 @@ import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { FiSettings } from "react-icons/fi";
|
||||
import { SettingsForm } from "./SettingsForm";
|
||||
import { Text } from "@tremor/react";
|
||||
import { SettingsIcon } from "@/components/icons/icons";
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
<AdminPageTitle
|
||||
title="Workspace Settings"
|
||||
icon={<FiSettings size={32} className="my-auto" />}
|
||||
icon={<SettingsIcon size={32} className="my-auto" />}
|
||||
/>
|
||||
|
||||
<Text className="mb-8">
|
||||
|
||||
@@ -23,6 +23,7 @@ import { mutate } from "swr";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { CreateRateLimitModal } from "./CreateRateLimitModal";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { ShieldIcon } from "@/components/icons/icons";
|
||||
|
||||
const BASE_URL = "/api/admin/token-rate-limits";
|
||||
const GLOBAL_TOKEN_FETCH_URL = `${BASE_URL}/global`;
|
||||
@@ -219,8 +220,10 @@ function Main() {
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
<AdminPageTitle title="Token Rate Limits" icon={<FiShield size={32} />} />
|
||||
|
||||
<AdminPageTitle
|
||||
title="Token Rate Limits"
|
||||
icon={<ShieldIcon size={32} />}
|
||||
/>
|
||||
<Main />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DeleteToolButton } from "./DeleteToolButton";
|
||||
import { FiTool } from "react-icons/fi";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
import { ToolIcon } from "@/components/icons/icons";
|
||||
|
||||
export default async function Page({ params }: { params: { toolId: string } }) {
|
||||
const tool = await fetchToolByIdSS(params.toolId);
|
||||
@@ -46,7 +47,7 @@ export default async function Page({ params }: { params: { toolId: string } }) {
|
||||
|
||||
<AdminPageTitle
|
||||
title="Edit Tool"
|
||||
icon={<FiTool size={32} className="my-auto" />}
|
||||
icon={<ToolIcon size={32} className="my-auto" />}
|
||||
/>
|
||||
|
||||
{body}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ToolEditor } from "@/app/admin/tools/ToolEditor";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { ToolIcon } from "@/components/icons/icons";
|
||||
import { Card } from "@tremor/react";
|
||||
import { FiTool } from "react-icons/fi";
|
||||
|
||||
@@ -13,7 +14,7 @@ export default function NewToolPage() {
|
||||
|
||||
<AdminPageTitle
|
||||
title="Create Tool"
|
||||
icon={<FiTool size={32} className="my-auto" />}
|
||||
icon={<ToolIcon size={32} className="my-auto" />}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Divider, Text, Title } from "@tremor/react";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { ToolIcon } from "@/components/icons/icons";
|
||||
|
||||
export default async function Page() {
|
||||
const toolResponse = await fetchSS("/tool");
|
||||
@@ -24,7 +25,7 @@ export default async function Page() {
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
<AdminPageTitle
|
||||
icon={<FiTool size={32} className="my-auto" />}
|
||||
icon={<ToolIcon size={32} className="my-auto" />}
|
||||
title="Tools"
|
||||
/>
|
||||
|
||||
|
||||
149
web/src/app/assistants/SidebarWrapper.tsx
Normal file
149
web/src/app/assistants/SidebarWrapper.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar";
|
||||
import { AssistantsGallery } from "./gallery/AssistantsGallery";
|
||||
import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo";
|
||||
import { UserDropdown } from "@/components/UserDropdown";
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { Folder } from "@/app/chat/folders/interfaces";
|
||||
import { User } from "@/lib/types";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import Cookies from "js-cookie";
|
||||
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useSidebarVisibility } from "@/components/chat_search/hooks";
|
||||
import FunctionalHeader from "@/components/chat_search/Header";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface SidebarWrapperProps<T extends object> {
|
||||
chatSessions: ChatSession[];
|
||||
folders: Folder[];
|
||||
initiallyToggled: boolean;
|
||||
openedFolders?: { [key: number]: boolean };
|
||||
content: (props: T) => ReactNode;
|
||||
headerProps: {
|
||||
page: string;
|
||||
user: User | null;
|
||||
};
|
||||
contentProps: T;
|
||||
}
|
||||
|
||||
export default function SidebarWrapper<T extends object>({
|
||||
chatSessions,
|
||||
initiallyToggled,
|
||||
folders,
|
||||
openedFolders,
|
||||
headerProps,
|
||||
contentProps,
|
||||
content,
|
||||
}: SidebarWrapperProps<T>) {
|
||||
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
|
||||
|
||||
const [showDocSidebar, setShowDocSidebar] = useState(false); // State to track if sidebar is open
|
||||
|
||||
const toggleSidebar = () => {
|
||||
Cookies.set(
|
||||
SIDEBAR_TOGGLED_COOKIE_NAME,
|
||||
String(!toggledSidebar).toLocaleLowerCase()
|
||||
),
|
||||
{
|
||||
path: "/",
|
||||
};
|
||||
setToggledSidebar((toggledSidebar) => !toggledSidebar);
|
||||
};
|
||||
|
||||
const sidebarElementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useSidebarVisibility({
|
||||
toggledSidebar,
|
||||
sidebarElementRef,
|
||||
showDocSidebar,
|
||||
setShowDocSidebar,
|
||||
});
|
||||
|
||||
const innerSidebarElementRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
switch (event.key.toLowerCase()) {
|
||||
case "e":
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex relative overflow-x-hidden overscroll-contain flex-col w-full h-screen">
|
||||
<div
|
||||
ref={sidebarElementRef}
|
||||
className={`
|
||||
flex-none
|
||||
fixed
|
||||
left-0
|
||||
z-20
|
||||
overflow-y-hidden
|
||||
sidebar
|
||||
bg-background-100
|
||||
h-screen
|
||||
transition-all
|
||||
bg-opacity-80
|
||||
duration-300
|
||||
ease-in-out
|
||||
${
|
||||
showDocSidebar || toggledSidebar
|
||||
? "opacity-100 w-[300px] translate-x-0"
|
||||
: "opacity-0 w-[200px] pointer-events-none -translate-x-10"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full relative">
|
||||
<HistorySidebar
|
||||
page="chat"
|
||||
ref={innerSidebarElementRef}
|
||||
toggleSidebar={toggleSidebar}
|
||||
toggled={toggledSidebar}
|
||||
existingChats={chatSessions}
|
||||
currentChatSession={null}
|
||||
folders={folders}
|
||||
openedFolders={openedFolders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 w-full top-0">
|
||||
<FunctionalHeader
|
||||
page="assistants"
|
||||
showSidebar={showDocSidebar}
|
||||
user={headerProps.user}
|
||||
/>
|
||||
<div className="w-full flex">
|
||||
<div
|
||||
style={{ transition: "width 0.30s ease-out" }}
|
||||
className={`
|
||||
flex-none
|
||||
overflow-y-hidden
|
||||
bg-background-100
|
||||
h-full
|
||||
transition-all
|
||||
bg-opacity-80
|
||||
duration-300
|
||||
ease-in-out
|
||||
${toggledSidebar ? "w-[300px]" : "w-[0px]"}
|
||||
`}
|
||||
/>
|
||||
|
||||
<div className="mt-4 mx-auto">{content(contentProps)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<FixedLogo />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { FiImage, FiSearch, FiGlobe } from "react-icons/fi";
|
||||
export function ToolsDisplay({ tools }: { tools: ToolSnapshot[] }) {
|
||||
return (
|
||||
<div className="text-xs text-subtle flex flex-wrap gap-1 mt-2">
|
||||
<p className="text-sm text-default my-auto">Tools:</p>
|
||||
{tools.map((tool) => {
|
||||
let toolName = tool.name;
|
||||
let toolIcon = null;
|
||||
|
||||
44
web/src/app/assistants/gallery/WrappedAssistantsGallery.tsx
Normal file
44
web/src/app/assistants/gallery/WrappedAssistantsGallery.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import SidebarWrapper from "../SidebarWrapper";
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { Folder } from "@/app/chat/folders/interfaces";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { User } from "@/lib/types";
|
||||
import { AssistantsGallery } from "./AssistantsGallery";
|
||||
|
||||
export default function WrappedAssistantsGallery({
|
||||
chatSessions,
|
||||
initiallyToggled,
|
||||
folders,
|
||||
openedFolders,
|
||||
user,
|
||||
assistants,
|
||||
}: {
|
||||
chatSessions: ChatSession[];
|
||||
folders: Folder[];
|
||||
initiallyToggled: boolean;
|
||||
openedFolders?: { [key: number]: boolean };
|
||||
user: User | null;
|
||||
assistants: Persona[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarWrapper
|
||||
initiallyToggled={initiallyToggled}
|
||||
chatSessions={chatSessions}
|
||||
folders={folders}
|
||||
openedFolders={openedFolders}
|
||||
headerProps={{ user, page: "" }}
|
||||
contentProps={{
|
||||
assistants: assistants,
|
||||
user: user,
|
||||
}}
|
||||
content={(contentProps) => (
|
||||
<AssistantsGallery
|
||||
assistants={contentProps.assistants}
|
||||
user={contentProps.user}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChatSidebar } from "@/app/chat/sessionSidebar/ChatSidebar";
|
||||
import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import { UserDropdown } from "@/components/UserDropdown";
|
||||
import { ChatProvider } from "@/components/context/ChatContext";
|
||||
@@ -7,6 +7,9 @@ import { fetchChatData } from "@/lib/chat/fetchChatData";
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AssistantsGallery } from "./AssistantsGallery";
|
||||
import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo";
|
||||
import GalleryWrapper from "../SidebarWrapper";
|
||||
import WrappedAssistantsGallery from "./WrappedAssistantsGallery";
|
||||
|
||||
export default async function GalleryPage({
|
||||
searchParams,
|
||||
@@ -32,6 +35,7 @@ export default async function GalleryPage({
|
||||
folders,
|
||||
openedFolders,
|
||||
shouldShowWelcomeModal,
|
||||
toggleSidebar,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
@@ -53,28 +57,14 @@ export default async function GalleryPage({
|
||||
openedFolders,
|
||||
}}
|
||||
>
|
||||
<div className="flex relative bg-background text-default overflow-x-hidden h-screen">
|
||||
<ChatSidebar
|
||||
existingChats={chatSessions}
|
||||
currentChatSession={null}
|
||||
folders={folders}
|
||||
openedFolders={openedFolders}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`w-full h-full flex flex-col overflow-y-auto overflow-x-hidden relative`}
|
||||
>
|
||||
<div className="sticky top-0 left-80 z-10 w-full bg-background flex h-fit">
|
||||
<div className="ml-auto my-auto mt-4 mr-8">
|
||||
<UserDropdown user={user} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<AssistantsGallery assistants={assistants} user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WrappedAssistantsGallery
|
||||
initiallyToggled={toggleSidebar}
|
||||
chatSessions={chatSessions}
|
||||
folders={folders}
|
||||
openedFolders={openedFolders}
|
||||
user={user}
|
||||
assistants={assistants}
|
||||
/>
|
||||
</ChatProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
43
web/src/app/assistants/mine/WrappedAssistantsMine.tsx
Normal file
43
web/src/app/assistants/mine/WrappedAssistantsMine.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
import { AssistantsList } from "./AssistantsList";
|
||||
import SidebarWrapper from "../SidebarWrapper";
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { Folder } from "@/app/chat/folders/interfaces";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { User } from "@/lib/types";
|
||||
|
||||
export default function WrappedAssistantsMine({
|
||||
chatSessions,
|
||||
initiallyToggled,
|
||||
folders,
|
||||
openedFolders,
|
||||
user,
|
||||
assistants,
|
||||
}: {
|
||||
chatSessions: ChatSession[];
|
||||
folders: Folder[];
|
||||
initiallyToggled: boolean;
|
||||
openedFolders?: { [key: number]: boolean };
|
||||
user: User | null;
|
||||
assistants: Persona[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarWrapper
|
||||
initiallyToggled={initiallyToggled}
|
||||
chatSessions={chatSessions}
|
||||
folders={folders}
|
||||
openedFolders={openedFolders}
|
||||
headerProps={{ user, page: "" }}
|
||||
contentProps={{
|
||||
assistants: assistants,
|
||||
user: user,
|
||||
}}
|
||||
content={(contentProps) => (
|
||||
<AssistantsList
|
||||
assistants={contentProps.assistants}
|
||||
user={contentProps.user}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChatSidebar } from "@/app/chat/sessionSidebar/ChatSidebar";
|
||||
import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import { UserDropdown } from "@/components/UserDropdown";
|
||||
import { ChatProvider } from "@/components/context/ChatContext";
|
||||
@@ -8,6 +8,10 @@ import { fetchChatData } from "@/lib/chat/fetchChatData";
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AssistantsList } from "./AssistantsList";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo";
|
||||
import SidebarWrapper from "../SidebarWrapper";
|
||||
import WrappedAssistantsMine from "./WrappedAssistantsMine";
|
||||
|
||||
export default async function GalleryPage({
|
||||
searchParams,
|
||||
@@ -33,6 +37,7 @@ export default async function GalleryPage({
|
||||
folders,
|
||||
openedFolders,
|
||||
shouldShowWelcomeModal,
|
||||
toggleSidebar,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
@@ -54,28 +59,14 @@ export default async function GalleryPage({
|
||||
openedFolders,
|
||||
}}
|
||||
>
|
||||
<div className="flex relative bg-background text-default overflow-x-hidden h-screen">
|
||||
<ChatSidebar
|
||||
existingChats={chatSessions}
|
||||
currentChatSession={null}
|
||||
folders={folders}
|
||||
openedFolders={openedFolders}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`w-full h-screen flex flex-col overflow-y-auto overflow-x-hidden relative`}
|
||||
>
|
||||
<div className="sticky top-0 left-80 z-10 w-full bg-background flex h-fit">
|
||||
<div className="ml-auto my-auto mt-4 mr-8">
|
||||
<UserDropdown user={user} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<AssistantsList user={user} assistants={assistants} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WrappedAssistantsMine
|
||||
initiallyToggled={toggleSidebar}
|
||||
chatSessions={chatSessions}
|
||||
folders={folders}
|
||||
openedFolders={openedFolders}
|
||||
user={user}
|
||||
assistants={assistants}
|
||||
/>
|
||||
</ChatProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SignInButton({
|
||||
|
||||
return (
|
||||
<a
|
||||
className="mt-6 py-3 w-72 text-gray-100 bg-accent flex rounded cursor-pointer hover:bg-indigo-800"
|
||||
className="mt-6 py-3 w-72 text-text-100 bg-accent flex rounded cursor-pointer hover:bg-indigo-800"
|
||||
href={authorizeUrl}
|
||||
>
|
||||
{button}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ChatBanner() {
|
||||
className={`
|
||||
z-[39]
|
||||
h-[30px]
|
||||
bg-background-custom-header
|
||||
bg-background-100
|
||||
shadow-sm
|
||||
m-2
|
||||
rounded
|
||||
|
||||
@@ -35,17 +35,13 @@ export function ChatIntro({
|
||||
}) {
|
||||
const availableSourceMetadata = getSourceMetadataForSources(availableSources);
|
||||
|
||||
const [displaySources, setDisplaySources] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<div className="w-message-xs 2xl:w-message-sm 3xl:w-message">
|
||||
<div className="flex">
|
||||
<div className="mx-auto">
|
||||
<Logo height={80} width={80} className="m-auto" />
|
||||
|
||||
<div className="m-auto text-3xl font-bold text-strong mt-4 w-fit">
|
||||
<div className="m-auto text-3xl font-strong font-bold text-strong w-fit">
|
||||
{selectedPersona?.name || "How can I help you today?"}
|
||||
</div>
|
||||
{selectedPersona && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
26
web/src/app/chat/WrappedChat.tsx
Normal file
26
web/src/app/chat/WrappedChat.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChatPage } from "./ChatPage";
|
||||
import FunctionalWrapper from "./shared_chat_search/FunctionalWrapper";
|
||||
|
||||
export default function WrappedChat({
|
||||
defaultPersonaId,
|
||||
initiallyToggled,
|
||||
}: {
|
||||
defaultPersonaId?: number;
|
||||
initiallyToggled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<FunctionalWrapper
|
||||
initiallyToggled={initiallyToggled}
|
||||
content={(toggledSidebar, toggle) => (
|
||||
<ChatPage
|
||||
toggle={toggle}
|
||||
defaultSelectedPersonaId={defaultPersonaId}
|
||||
toggledSidebar={toggledSidebar}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { HoverPopup } from "@/components/HoverPopup";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { DocumentFeedbackBlock } from "@/components/search/DocumentFeedbackBlock";
|
||||
import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge";
|
||||
import { DanswerDocument } from "@/lib/search/interfaces";
|
||||
import { FiInfo, FiRadio } from "react-icons/fi";
|
||||
import { DocumentSelector } from "./DocumentSelector";
|
||||
@@ -32,25 +30,32 @@ export function ChatDocumentDisplay({
|
||||
tokenLimitReached,
|
||||
}: DocumentDisplayProps) {
|
||||
const isInternet = document.is_internet;
|
||||
// Consider reintroducing null scored docs in the future
|
||||
|
||||
if (document.score === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={document.semantic_identifier} className="text-sm px-3">
|
||||
<div className="flex relative w-full overflow-y-visible">
|
||||
<div
|
||||
key={document.semantic_identifier}
|
||||
className={`p-2 w-[350px] justify-start rounded-md ${
|
||||
isSelected ? "bg-background-200" : "bg-background-125"
|
||||
} text-sm mx-3`}
|
||||
>
|
||||
<div className="flex relative justify-start overflow-y-visible">
|
||||
<a
|
||||
className={
|
||||
"rounded-lg flex font-bold flex-shrink truncate items-center " +
|
||||
"rounded-lg flex font-bold flex-shrink truncate" +
|
||||
(document.link ? "" : "pointer-events-none")
|
||||
}
|
||||
href={document.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{isInternet ? (
|
||||
<InternetSearchIcon url={document.link} />
|
||||
) : (
|
||||
<SourceIcon sourceType={document.source_type} iconSize={18} />
|
||||
)}
|
||||
<p className="overflow-hidden text-ellipsis mx-2 my-auto text-sm ">
|
||||
<p className="overflow-hidden text-left text-ellipsis mx-2 my-auto text-sm ">
|
||||
{document.semantic_identifier || document.document_id}
|
||||
</p>
|
||||
</a>
|
||||
@@ -75,6 +80,21 @@ export function ChatDocumentDisplay({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
text-xs
|
||||
text-emphasis
|
||||
bg-hover
|
||||
rounded
|
||||
p-0.5
|
||||
w-fit
|
||||
my-auto
|
||||
select-none
|
||||
my-auto
|
||||
mr-2`}
|
||||
>
|
||||
{Math.abs(document.score).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -91,8 +111,9 @@ export function ChatDocumentDisplay({
|
||||
<DocumentMetadataBlock document={document} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="pl-1 pt-2 pb-1 break-words">
|
||||
<p className="line-clamp-3 pl-1 pt-2 mb-1 text-start break-words">
|
||||
{buildDocumentSummaryDisplay(document.match_highlights, document.blurb)}
|
||||
test
|
||||
</p>
|
||||
<div className="mb-2">
|
||||
{/*
|
||||
|
||||
@@ -32,9 +32,8 @@ export function DocumentSelector({
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
<p className="mr-2 my-auto">Select</p>
|
||||
<input
|
||||
className="my-auto"
|
||||
className="cursor-pointer my-auto"
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
// dummy function to prevent warning
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DanswerDocument } from "@/lib/search/interfaces";
|
||||
import { Text } from "@tremor/react";
|
||||
import { Divider, Text } from "@tremor/react";
|
||||
import { ChatDocumentDisplay } from "./ChatDocumentDisplay";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { FiAlertTriangle, FiFileText } from "react-icons/fi";
|
||||
@@ -50,6 +50,7 @@ interface DocumentSidebarProps {
|
||||
maxTokens: number;
|
||||
isLoading: boolean;
|
||||
initialWidth: number;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const DocumentSidebar = forwardRef<HTMLDivElement, DocumentSidebarProps>(
|
||||
@@ -64,6 +65,7 @@ export const DocumentSidebar = forwardRef<HTMLDivElement, DocumentSidebarProps>(
|
||||
maxTokens,
|
||||
isLoading,
|
||||
initialWidth,
|
||||
isOpen,
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
@@ -86,43 +88,53 @@ export const DocumentSidebar = forwardRef<HTMLDivElement, DocumentSidebarProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: initialWidth }}
|
||||
ref={ref}
|
||||
className={`sidebar absolute right-0 h-screen border-l border-l-border`}
|
||||
className={`fixed inset-0 transition-opacity duration-300 z-50 bg-black/80 ${
|
||||
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeSidebar();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full flex-initial
|
||||
overflow-y-hidden
|
||||
flex
|
||||
flex-col h-screen"
|
||||
className={`ml-auto rounded-l-lg relative border-l bg-text-100 sidebar z-50 absolute right-0 h-screen transition-all duration-300 ${
|
||||
isOpen ? "opacity-100 translate-x-0" : "opacity-0 translate-x-[10%]"
|
||||
}`}
|
||||
ref={ref}
|
||||
style={{
|
||||
width: initialWidth,
|
||||
}}
|
||||
>
|
||||
{popup}
|
||||
|
||||
<div className="h-4/6 flex flex-col">
|
||||
<div className="pl-3 pr-6 mb-3 flex border-b border-border">
|
||||
<SectionHeader
|
||||
name={
|
||||
selectedMessageRetrievalType === RetrievalType.SelectedDocs
|
||||
? "Referenced Documents"
|
||||
: "Retrieved Documents"
|
||||
}
|
||||
icon={FiFileText}
|
||||
closeHeader={closeSidebar}
|
||||
/>
|
||||
<div className="pb-6 flex-initial overflow-y-hidden flex flex-col h-screen ">
|
||||
{popup}
|
||||
<div className="pl-3 mx-2 pr-6 mt-3 flex text-text-800 flex-col text-2xl text-emphasis flex font-semibold">
|
||||
{dedupedDocuments.length} Documents
|
||||
<p className="text-sm font-semibold flex flex-wrap gap-x-2 text-text-600 mt-1">
|
||||
Select to add to continuous context
|
||||
<a
|
||||
href="https://docs.danswer.dev/introduction"
|
||||
className="underline cursor-pointer hover:text-strong"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Divider className="mb-0 mt-4 pb-2" />
|
||||
|
||||
{currentDocuments ? (
|
||||
<div className="overflow-y-auto dark-scrollbar flex flex-col">
|
||||
<div className="overflow-y-auto flex-grow dark-scrollbar flex relative flex-col">
|
||||
<div>
|
||||
{dedupedDocuments.length > 0 ? (
|
||||
dedupedDocuments.map((document, ind) => (
|
||||
<div
|
||||
key={document.document_id}
|
||||
className={
|
||||
className={`${
|
||||
ind === dedupedDocuments.length - 1
|
||||
? "mb-5"
|
||||
: "border-b border-border-light mb-3"
|
||||
}
|
||||
: "border-b border-border-light mb-3"
|
||||
}`}
|
||||
>
|
||||
<ChatDocumentDisplay
|
||||
document={document}
|
||||
@@ -163,84 +175,25 @@ export const DocumentSidebar = forwardRef<HTMLDivElement, DocumentSidebarProps>(
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm mb-4 border-t border-border pt-4 overflow-y-hidden flex flex-col">
|
||||
<div className="flex border-b border-border px-3">
|
||||
<div className="flex">
|
||||
<SectionHeader name="Selected Documents" icon={FiFileText} />
|
||||
{tokenLimitReached && (
|
||||
<div className="ml-2 my-auto">
|
||||
<div className="mb-2">
|
||||
<HoverPopup
|
||||
mainContent={
|
||||
<FiAlertTriangle
|
||||
className="text-alert my-auto"
|
||||
size="16"
|
||||
/>
|
||||
}
|
||||
popupContent={
|
||||
<Text className="w-40">
|
||||
Over LLM context length by:{" "}
|
||||
<i>{selectedDocumentTokens - maxTokens} tokens</i>
|
||||
<br />
|
||||
<br />
|
||||
{selectedDocuments &&
|
||||
selectedDocuments.length > 0 && (
|
||||
<>
|
||||
Truncating: "
|
||||
<i>
|
||||
{
|
||||
selectedDocuments[
|
||||
selectedDocuments.length - 1
|
||||
].semantic_identifier
|
||||
}
|
||||
</i>
|
||||
"
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
}
|
||||
direction="left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedDocuments && selectedDocuments.length > 0 && (
|
||||
<div
|
||||
className="ml-auto my-auto"
|
||||
onClick={clearSelectedDocuments}
|
||||
>
|
||||
<BasicSelectable selected={false}>
|
||||
De-Select All
|
||||
</BasicSelectable>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute left-0 bottom-0 w-full bg-gradient-to-b from-neutral-100/0 via-neutral-100/40 backdrop-blur-xs to-neutral-100 h-[100px]" />
|
||||
<div className="sticky bottom-4 w-full left-0 justify-center flex gap-x-4">
|
||||
<button
|
||||
className="bg-[#84e49e] text-xs p-2 rounded text-text-800"
|
||||
onClick={() => closeSidebar()}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
|
||||
{selectedDocuments && selectedDocuments.length > 0 ? (
|
||||
<div className="flex flex-col gap-y-2 py-3 px-3 overflow-y-auto dark-scrollbar max-h-full">
|
||||
{selectedDocuments.map((document) => (
|
||||
<SelectedDocumentDisplay
|
||||
key={document.document_id}
|
||||
document={document}
|
||||
handleDeselect={(documentId) => {
|
||||
toggleDocumentSelection(
|
||||
dedupedDocuments.find(
|
||||
(document) => document.document_id === documentId
|
||||
)!
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!isLoading && (
|
||||
<Text className="mx-3 py-3">
|
||||
Select documents from the retrieved documents section to chat
|
||||
specifically with them!
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
className="bg-error text-xs p-2 rounded text-text-200"
|
||||
onClick={() => {
|
||||
clearSelectedDocuments();
|
||||
|
||||
closeSidebar();
|
||||
}}
|
||||
>
|
||||
Delete Context
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChatFileType, FileDescriptor } from "../interfaces";
|
||||
import { DocumentPreview } from "./documents/DocumentPreview";
|
||||
|
||||
import { FiX, FiLoader, FiFileText } from "react-icons/fi";
|
||||
import { InputBarPreviewImage } from "./images/InputBarPreviewImage";
|
||||
import { FiX, FiLoader } from "react-icons/fi";
|
||||
import { Tooltip } from "@/components/tooltip/Tooltip";
|
||||
|
||||
function DeleteButton({ onDelete }: { onDelete: () => void }) {
|
||||
return (
|
||||
@@ -15,7 +16,7 @@ function DeleteButton({ onDelete }: { onDelete: () => void }) {
|
||||
cursor-pointer
|
||||
border-none
|
||||
bg-hover
|
||||
p-1
|
||||
p-.5
|
||||
rounded-full
|
||||
z-10
|
||||
"
|
||||
@@ -25,6 +26,45 @@ function DeleteButton({ onDelete }: { onDelete: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function InputBarPreviewImageProvider({
|
||||
file,
|
||||
onDelete,
|
||||
isUploading,
|
||||
}: {
|
||||
file: FileDescriptor;
|
||||
onDelete: () => void;
|
||||
isUploading: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-10 relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{isHovered && <DeleteButton onDelete={onDelete} />}
|
||||
{isUploading && (
|
||||
<div
|
||||
className="
|
||||
absolute
|
||||
inset-0
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
bg-opacity-50
|
||||
rounded-lg
|
||||
z-0
|
||||
"
|
||||
>
|
||||
<FiLoader className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
<InputBarPreviewImage fileId={file.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputBarPreview({
|
||||
file,
|
||||
onDelete,
|
||||
@@ -36,12 +76,16 @@ export function InputBarPreview({
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const renderContent = () => {
|
||||
if (file.type === ChatFileType.IMAGE) {
|
||||
return <InputBarPreviewImage fileId={file.id} />;
|
||||
const fileNameRef = useRef<HTMLDivElement>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileNameRef.current) {
|
||||
setIsOverflowing(
|
||||
fileNameRef.current.scrollWidth > fileNameRef.current.clientWidth
|
||||
);
|
||||
}
|
||||
return <DocumentPreview fileName={file.name || file.id} />;
|
||||
};
|
||||
}, [file.name]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -49,7 +93,6 @@ export function InputBarPreview({
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{isHovered && <DeleteButton onDelete={onDelete} />}
|
||||
{isUploading && (
|
||||
<div
|
||||
className="
|
||||
@@ -58,7 +101,6 @@ export function InputBarPreview({
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
bg-black
|
||||
bg-opacity-50
|
||||
rounded-lg
|
||||
z-0
|
||||
@@ -67,7 +109,58 @@ export function InputBarPreview({
|
||||
<FiLoader className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
<div
|
||||
className={`
|
||||
flex
|
||||
items-center
|
||||
p-2
|
||||
bg-hover
|
||||
border
|
||||
border-border
|
||||
rounded-md
|
||||
box-border
|
||||
h-10
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className="
|
||||
w-6
|
||||
h-6
|
||||
bg-document
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
rounded-md
|
||||
"
|
||||
>
|
||||
<FiFileText className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 relative">
|
||||
<Tooltip content={file.name} side="top" align="start">
|
||||
<div
|
||||
ref={fileNameRef}
|
||||
className={`font-medium text-sm line-clamp-1 break-all ellipses max-w-48`}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="
|
||||
cursor-pointer
|
||||
border-none
|
||||
bg-hover
|
||||
p-1
|
||||
rounded-full
|
||||
z-10
|
||||
"
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import { Tooltip } from "@/components/tooltip/Tooltip";
|
||||
export function DocumentPreview({
|
||||
fileName,
|
||||
maxWidth,
|
||||
alignBubble,
|
||||
}: {
|
||||
fileName: string;
|
||||
maxWidth?: string;
|
||||
alignBubble?: boolean;
|
||||
}) {
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const fileNameRef = useRef<HTMLDivElement>(null);
|
||||
@@ -22,7 +24,8 @@ export function DocumentPreview({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
className={`
|
||||
${alignBubble && "w-64"}
|
||||
flex
|
||||
items-center
|
||||
p-2
|
||||
@@ -32,7 +35,7 @@ export function DocumentPreview({
|
||||
rounded-md
|
||||
box-border
|
||||
h-16
|
||||
"
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
@@ -53,7 +56,7 @@ export function DocumentPreview({
|
||||
<Tooltip content={fileName} side="top" align="start">
|
||||
<div
|
||||
ref={fileNameRef}
|
||||
className={`font-medium text-sm truncate ${
|
||||
className={`font-medium text-sm line-clamp-1 break-all ellipses ${
|
||||
maxWidth ? maxWidth : "max-w-48"
|
||||
}`}
|
||||
>
|
||||
@@ -65,3 +68,69 @@ export function DocumentPreview({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputDocumentPreview({
|
||||
fileName,
|
||||
maxWidth,
|
||||
alignBubble,
|
||||
}: {
|
||||
fileName: string;
|
||||
maxWidth?: string;
|
||||
alignBubble?: boolean;
|
||||
}) {
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const fileNameRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileNameRef.current) {
|
||||
setIsOverflowing(
|
||||
fileNameRef.current.scrollWidth > fileNameRef.current.clientWidth
|
||||
);
|
||||
}
|
||||
}, [fileName]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${alignBubble && "w-64"}
|
||||
flex
|
||||
items-center
|
||||
p-2
|
||||
bg-hover
|
||||
border
|
||||
border-border
|
||||
rounded-md
|
||||
box-border
|
||||
h-10
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className="
|
||||
w-6
|
||||
h-6
|
||||
bg-document
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
rounded-md
|
||||
"
|
||||
>
|
||||
<FiFileText className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 relative">
|
||||
<Tooltip content={fileName} side="top" align="start">
|
||||
<div
|
||||
ref={fileNameRef}
|
||||
className={`font-medium text-sm line-clamp-1 break-all ellipses ${
|
||||
maxWidth ? maxWidth : "max-w-48"
|
||||
}`}
|
||||
>
|
||||
{fileName}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,7 @@ export function InMessageImage({ fileId }: { fileId: string }) {
|
||||
/>
|
||||
|
||||
<img
|
||||
className={`
|
||||
max-w-lg
|
||||
rounded-lg
|
||||
bg-transparent
|
||||
cursor-pointer
|
||||
transition-opacity
|
||||
duration-300
|
||||
opacity-100`}
|
||||
className="object-cover object-center overflow-hidden rounded-lg w-full h-full max-w-64 max-h-64 transition-opacity duration-300 opacity-100"
|
||||
onClick={() => setFullImageShowing(true)}
|
||||
src={buildImgUrl(fileId)}
|
||||
loading="lazy"
|
||||
|
||||
@@ -14,10 +14,24 @@ export function InputBarPreviewImage({ fileId }: { fileId: string }) {
|
||||
open={fullImageShowing}
|
||||
onOpenChange={(open) => setFullImageShowing(open)}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className={`
|
||||
bg-transparent
|
||||
border-none
|
||||
flex
|
||||
items-center
|
||||
p-2
|
||||
bg-hover
|
||||
border
|
||||
border-border
|
||||
rounded-md
|
||||
box-border
|
||||
h-10
|
||||
`}
|
||||
>
|
||||
<img
|
||||
onClick={() => setFullImageShowing(true)}
|
||||
className="h-16 w-16 object-cover rounded-lg bg-background cursor-pointer"
|
||||
className="h-8 w-8 object-cover rounded-lg bg-background cursor-pointer"
|
||||
src={buildImgUrl(fileId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Folder } from "./interfaces";
|
||||
import { ChatSessionDisplay } from "../sessionSidebar/ChatSessionDisplay"; // Ensure this is correctly imported
|
||||
import {
|
||||
@@ -22,6 +22,9 @@ import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CHAT_SESSION_ID_KEY } from "@/lib/drag/constants";
|
||||
import Cookies from "js-cookie";
|
||||
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
|
||||
import { Tooltip } from "@/components/tooltip/Tooltip";
|
||||
import { Popover } from "@/components/popover/Popover";
|
||||
|
||||
const FolderItem = ({
|
||||
folder,
|
||||
@@ -54,6 +57,8 @@ const FolderItem = ({
|
||||
if (newIsExpanded) {
|
||||
openedFolders[folder.folder_id] = true;
|
||||
} else {
|
||||
setShowDeleteConfirm(false);
|
||||
|
||||
delete openedFolders[folder.folder_id];
|
||||
}
|
||||
Cookies.set("openedFolders", JSON.stringify(openedFolders));
|
||||
@@ -87,18 +92,47 @@ const FolderItem = ({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFolderHandler = async (
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
event.stopPropagation(); // Prevent the event from bubbling up to the toggle expansion
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
const deleteConfirmRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDeleteClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await deleteFolder(folder.folder_id);
|
||||
router.refresh(); // Refresh values to update the sidebar
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setPopup({ message: "Failed to delete folder", type: "error" });
|
||||
} finally {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
deleteConfirmRef.current &&
|
||||
!deleteConfirmRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
@@ -134,13 +168,38 @@ const FolderItem = ({
|
||||
isDragOver ? "bg-hover" : ""
|
||||
}`}
|
||||
>
|
||||
{showDeleteConfirm && (
|
||||
<div
|
||||
ref={deleteConfirmRef}
|
||||
className="absolute max-w-xs border z-[100] border-neutral-300 top-0 right-0 w-[250px] -bo-0 top-2 mt-4 p-2 bg-background-100 rounded shadow-lg z-10"
|
||||
>
|
||||
<p className="text-sm mb-2">
|
||||
Are you sure you want to delete <i>{folder.folder_name}</i>? All the
|
||||
content inside this folder will also be deleted
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs mr-2"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDelete}
|
||||
className="bg-gray-300 hover:bg-gray-200 px-2 py-1 rounded text-xs"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<BasicSelectable fullWidth selected={false}>
|
||||
<div
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<div onClick={toggleFolderExpansion} className="cursor-pointer">
|
||||
<div className="text-sm text-medium flex items-center justify-start w-full">
|
||||
<div className="text-sm text-text-600 flex items-center justify-start w-full">
|
||||
<div className="mr-2">
|
||||
{isExpanded ? (
|
||||
<FiChevronDown size={16} />
|
||||
@@ -172,14 +231,24 @@ const FolderItem = ({
|
||||
>
|
||||
<FiEdit2 size={16} />
|
||||
</div>
|
||||
<div
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={handleDeleteClick}
|
||||
className="hover:bg-black/10 p-1 -m-1 rounded ml-2"
|
||||
>
|
||||
<FiTrash size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div
|
||||
onClick={deleteFolderHandler}
|
||||
className="hover:bg-black/10 p-1 -m-1 rounded ml-2"
|
||||
>
|
||||
<FiTrash size={16} />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex ml-auto my-auto">
|
||||
<div
|
||||
@@ -223,7 +292,7 @@ export const FolderList = ({
|
||||
}: {
|
||||
folders: Folder[];
|
||||
currentChatId?: number;
|
||||
openedFolders: { [key: number]: boolean };
|
||||
openedFolders?: { [key: number]: boolean };
|
||||
}) => {
|
||||
if (folders.length === 0) {
|
||||
return null;
|
||||
@@ -236,7 +305,9 @@ export const FolderList = ({
|
||||
key={folder.folder_id}
|
||||
folder={folder}
|
||||
currentChatId={currentChatId}
|
||||
isInitiallyExpanded={openedFolders[folder.folder_id] || false}
|
||||
isInitiallyExpanded={
|
||||
openedFolders ? openedFolders[folder.folder_id] || false : false
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
52
web/src/app/chat/input/ChatInputAssistant.tsx
Normal file
52
web/src/app/chat/input/ChatInputAssistant.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
|
||||
import { Tooltip } from "@/components/tooltip/Tooltip";
|
||||
import { ForwardedRef, forwardRef, useState } from "react";
|
||||
import { FiX } from "react-icons/fi";
|
||||
|
||||
interface DocumentSidebarProps {
|
||||
alternativeAssistant: Persona;
|
||||
unToggle: () => void;
|
||||
}
|
||||
|
||||
export const ChatInputAssistant = forwardRef<
|
||||
HTMLDivElement,
|
||||
DocumentSidebarProps
|
||||
>(({ alternativeAssistant, unToggle }, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className="flex-none h-10 duration-300 h-10 items-center rounded-lg bg-background-150"
|
||||
>
|
||||
<Tooltip
|
||||
content={
|
||||
<p className="max-w-xs flex">{alternativeAssistant.description}</p>
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className="p-2 gap-x-1 relative rounded-t-lg items-center flex"
|
||||
>
|
||||
<AssistantIcon assistant={alternativeAssistant} border />
|
||||
<p className="ml-1 line-clamp-1 ellipsis break-all my-auto">
|
||||
{alternativeAssistant.name}
|
||||
</p>
|
||||
<div
|
||||
className="rounded-lg rounded h-fit cursor-pointer"
|
||||
onClick={unToggle}
|
||||
>
|
||||
<FiX />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChatInputAssistant.displayName = "TempAssistant";
|
||||
export default ChatInputAssistant;
|
||||
@@ -1,36 +1,37 @@
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
FiSend,
|
||||
FiFilter,
|
||||
FiPlusCircle,
|
||||
FiCpu,
|
||||
FiX,
|
||||
FiPlus,
|
||||
FiInfo,
|
||||
} from "react-icons/fi";
|
||||
import ChatInputOption from "./ChatInputOption";
|
||||
import { FaBrain } from "react-icons/fa";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { FiPlusCircle, FiPlus, FiInfo, FiX } from "react-icons/fi";
|
||||
import { ChatInputOption } from "./ChatInputOption";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { FilterManager, LlmOverrideManager } from "@/lib/hooks";
|
||||
import { SelectedFilterDisplay } from "./SelectedFilterDisplay";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { getFinalLLM } from "@/lib/llm/utils";
|
||||
import { FileDescriptor } from "../interfaces";
|
||||
import { InputBarPreview } from "../files/InputBarPreview";
|
||||
import { RobotIcon } from "@/components/icons/icons";
|
||||
import { Hoverable } from "@/components/Hoverable";
|
||||
import { ChatFileType, FileDescriptor } from "../interfaces";
|
||||
import {
|
||||
InputBarPreview,
|
||||
InputBarPreviewImageProvider,
|
||||
} from "../files/InputBarPreview";
|
||||
import {
|
||||
AssistantsIconSkeleton,
|
||||
CpuIconSkeleton,
|
||||
FileIcon,
|
||||
SendIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { IconType } from "react-icons";
|
||||
import Popup from "../../../components/popup/Popup";
|
||||
import { LlmTab } from "../modal/configuration/LlmTab";
|
||||
import { AssistantsTab } from "../modal/configuration/AssistantsTab";
|
||||
import ChatInputAssistant from "./ChatInputAssistant";
|
||||
import { DanswerDocument } from "@/lib/search/interfaces";
|
||||
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
|
||||
import { Tooltip } from "@/components/tooltip/Tooltip";
|
||||
import { Hoverable } from "@/components/Hoverable";
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
|
||||
export function ChatInputBar({
|
||||
personas,
|
||||
showDocs,
|
||||
selectedDocuments,
|
||||
message,
|
||||
setMessage,
|
||||
onSubmit,
|
||||
@@ -42,13 +43,21 @@ export function ChatInputBar({
|
||||
onSetSelectedAssistant,
|
||||
selectedAssistant,
|
||||
files,
|
||||
|
||||
setSelectedAssistant,
|
||||
setFiles,
|
||||
handleFileUpload,
|
||||
setConfigModalActiveTab,
|
||||
textAreaRef,
|
||||
alternativeAssistant,
|
||||
chatSessionId,
|
||||
availableAssistants,
|
||||
}: {
|
||||
showDocs: () => void;
|
||||
selectedDocuments: DanswerDocument[];
|
||||
availableAssistants: Persona[];
|
||||
onSetSelectedAssistant: (alternativeAssistant: Persona | null) => void;
|
||||
setSelectedAssistant: (assistant: Persona) => void;
|
||||
personas: Persona[];
|
||||
message: string;
|
||||
setMessage: (message: string) => void;
|
||||
@@ -65,6 +74,7 @@ export function ChatInputBar({
|
||||
handleFileUpload: (files: File[]) => void;
|
||||
setConfigModalActiveTab: (tab: string) => void;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
chatSessionId?: number;
|
||||
}) {
|
||||
// handle re-sizing of the text area
|
||||
useEffect(() => {
|
||||
@@ -102,19 +112,6 @@ export function ChatInputBar({
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
const interactionsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const hideSuggestions = () => {
|
||||
setShowSuggestions(false);
|
||||
setAssistantIconIndex(0);
|
||||
};
|
||||
|
||||
// Update selected persona
|
||||
const updateCurrentPersona = (persona: Persona) => {
|
||||
onSetSelectedAssistant(persona.id == selectedAssistant.id ? null : persona);
|
||||
hideSuggestions();
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
// Click out of assistant suggestions
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -133,6 +130,18 @@ export function ChatInputBar({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideSuggestions = () => {
|
||||
setShowSuggestions(false);
|
||||
setAssistantIconIndex(0);
|
||||
};
|
||||
|
||||
// Update selected persona
|
||||
const updateCurrentPersona = (persona: Persona) => {
|
||||
onSetSelectedAssistant(persona.id == selectedAssistant.id ? null : persona);
|
||||
hideSuggestions();
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
// Complete user input handling
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = event.target.value;
|
||||
@@ -143,7 +152,6 @@ export function ChatInputBar({
|
||||
return;
|
||||
}
|
||||
|
||||
// If looking for an assistant...fup
|
||||
const match = text.match(/(?:\s|^)@(\w*)$/);
|
||||
if (match) {
|
||||
setShowSuggestions(true);
|
||||
@@ -179,7 +187,12 @@ export function ChatInputBar({
|
||||
filteredPersonas[assistantIconIndex >= 0 ? assistantIconIndex : 0];
|
||||
updateCurrentPersona(option);
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
}
|
||||
if (!showSuggestions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setAssistantIconIndex((assistantIconIndex) =>
|
||||
Math.min(assistantIconIndex + 1, filteredPersonas.length)
|
||||
@@ -197,13 +210,12 @@ export function ChatInputBar({
|
||||
<div className="flex justify-center pb-2 max-w-screen-lg mx-auto mb-2">
|
||||
<div
|
||||
className="
|
||||
w-full
|
||||
w-[90%]
|
||||
shrink
|
||||
bg-background
|
||||
relative
|
||||
px-4
|
||||
w-searchbar-xs
|
||||
2xl:w-searchbar-sm
|
||||
3xl:w-searchbar
|
||||
max-w-searchbar-max
|
||||
mx-auto
|
||||
"
|
||||
>
|
||||
@@ -212,16 +224,18 @@ export function ChatInputBar({
|
||||
ref={suggestionsRef}
|
||||
className="text-sm absolute inset-x-0 top-0 w-full transform -translate-y-full"
|
||||
>
|
||||
<div className="rounded-lg py-1.5 bg-white border border-border-medium overflow-hidden shadow-lg mx-2 px-1.5 mt-2 rounded z-10">
|
||||
<div className="rounded-lg py-1.5 bg-background border border-border-medium shadow-lg mx-2 px-1.5 mt-2 rounded z-10">
|
||||
{filteredPersonas.map((currentPersona, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`px-2 ${assistantIconIndex == index && "bg-hover"} rounded content-start flex gap-x-1 py-1.5 w-full hover:bg-hover cursor-pointer`}
|
||||
className={`px-2 ${
|
||||
assistantIconIndex == index && "bg-hover-lightish"
|
||||
} rounded rounded-lg content-start flex gap-x-1 py-2 w-full hover:bg-hover-lightish cursor-pointer`}
|
||||
onClick={() => {
|
||||
updateCurrentPersona(currentPersona);
|
||||
}}
|
||||
>
|
||||
<p className="font-bold ">{currentPersona.name}</p>
|
||||
<p className="font-bold">{currentPersona.name}</p>
|
||||
<p className="line-clamp-1">
|
||||
{currentPersona.id == selectedAssistant.id &&
|
||||
"(default) "}
|
||||
@@ -232,7 +246,9 @@ export function ChatInputBar({
|
||||
<a
|
||||
key={filteredPersonas.length}
|
||||
target="_blank"
|
||||
className={`${assistantIconIndex == filteredPersonas.length && "bg-hover"} px-3 flex gap-x-1 py-2 w-full items-center hover:bg-hover-light cursor-pointer"`}
|
||||
className={`${
|
||||
assistantIconIndex == filteredPersonas.length && "bg-hover"
|
||||
} rounded rounded-lg px-3 flex gap-x-1 py-2 w-full items-center hover:bg-hover-lightish cursor-pointer"`}
|
||||
href="/assistants/new"
|
||||
>
|
||||
<FiPlus size={17} />
|
||||
@@ -241,23 +257,21 @@ export function ChatInputBar({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<SelectedFilterDisplay filterManager={filterManager} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="
|
||||
opacity-100
|
||||
w-full
|
||||
h-fit
|
||||
bg-bl
|
||||
flex
|
||||
flex-col
|
||||
border
|
||||
border-border-medium
|
||||
border-[#E5E7EB]
|
||||
rounded-lg
|
||||
overflow-hidden
|
||||
bg-background-weak
|
||||
bg-background-100
|
||||
[&:has(textarea:focus)]::ring-1
|
||||
[&:has(textarea:focus)]::ring-black
|
||||
"
|
||||
@@ -266,13 +280,13 @@ export function ChatInputBar({
|
||||
<div className="flex flex-wrap gap-y-1 gap-x-2 px-2 pt-1.5 w-full">
|
||||
<div
|
||||
ref={interactionsRef}
|
||||
className="bg-background-subtle p-2 rounded-t-lg items-center flex w-full"
|
||||
className="bg-background-200 p-2 rounded-t-lg items-center flex w-full"
|
||||
>
|
||||
<AssistantIcon assistant={alternativeAssistant} border />
|
||||
<p className="ml-3 text-strong my-auto">
|
||||
{alternativeAssistant.name}
|
||||
</p>
|
||||
<div className="flex gap-x-1 ml-auto ">
|
||||
<div className="flex gap-x-1 ml-auto">
|
||||
<Tooltip
|
||||
content={
|
||||
<p className="max-w-xs flex flex-wrap">
|
||||
@@ -293,24 +307,50 @@ export function ChatInputBar({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-y-1 gap-x-2 px-2 pt-2">
|
||||
{files.map((file) => (
|
||||
<div key={file.id}>
|
||||
<InputBarPreview
|
||||
file={file}
|
||||
onDelete={() => {
|
||||
setFiles(
|
||||
files.filter(
|
||||
(fileInFilter) => fileInFilter.id !== file.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
isUploading={file.isUploading || false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{(selectedDocuments.length > 0 || files.length > 0) && (
|
||||
<div className="flex gap-x-2 px-2 pt-2">
|
||||
<div className="flex gap-x-1 px-2 overflow-y-auto overflow-x-scroll items-end miniscroll">
|
||||
{selectedDocuments.length > 0 && (
|
||||
<button
|
||||
onClick={showDocs}
|
||||
className="flex-none flex cursor-pointer hover:bg-background-200 transition-colors duration-300 h-10 p-1 items-center gap-x-1 rounded-lg bg-background-150 max-w-[100px]"
|
||||
>
|
||||
<FileIcon size={24} />
|
||||
<p className="text-xs">
|
||||
{selectedDocuments.length} selected
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
<div className="flex-none" key={file.id}>
|
||||
{file.type === ChatFileType.IMAGE ? (
|
||||
<InputBarPreviewImageProvider
|
||||
file={file}
|
||||
onDelete={() => {
|
||||
setFiles(
|
||||
files.filter(
|
||||
(fileInFilter) => fileInFilter.id !== file.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
isUploading={file.isUploading || false}
|
||||
/>
|
||||
) : (
|
||||
<InputBarPreview
|
||||
file={file}
|
||||
onDelete={() => {
|
||||
setFiles(
|
||||
files.filter(
|
||||
(fileInFilter) => fileInFilter.id !== file.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
isUploading={file.isUploading || false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -324,21 +364,20 @@ export function ChatInputBar({
|
||||
w-full
|
||||
shrink
|
||||
resize-none
|
||||
rounded-lg
|
||||
border-0
|
||||
bg-background-weak
|
||||
bg-background-100
|
||||
${
|
||||
textAreaRef.current &&
|
||||
textAreaRef.current.scrollHeight > MAX_INPUT_HEIGHT
|
||||
? "overflow-y-auto mt-2"
|
||||
: ""
|
||||
}
|
||||
overflow-hidden
|
||||
whitespace-normal
|
||||
break-word
|
||||
overscroll-contain
|
||||
outline-none
|
||||
placeholder-subtle
|
||||
overflow-hidden
|
||||
resize-none
|
||||
pl-4
|
||||
pr-12
|
||||
@@ -349,7 +388,7 @@ export function ChatInputBar({
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
role="textarea"
|
||||
aria-multiline
|
||||
placeholder="Send a message..."
|
||||
placeholder="Send a message or @ to tag an assistant..."
|
||||
value={message}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
@@ -364,39 +403,60 @@ export function ChatInputBar({
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
<div className="flex items-center space-x-3 mr-12 px-4 pb-2 overflow-hidden">
|
||||
<ChatInputOption
|
||||
flexPriority="shrink"
|
||||
name={selectedAssistant ? selectedAssistant.name : "Assistants"}
|
||||
icon={FaBrain}
|
||||
onClick={() => setConfigModalActiveTab("assistants")}
|
||||
/>
|
||||
|
||||
<ChatInputOption
|
||||
flexPriority="second"
|
||||
name={
|
||||
llmOverrideManager.llmOverride.modelName ||
|
||||
(selectedAssistant
|
||||
? selectedAssistant.llm_model_version_override || llmName
|
||||
: llmName)
|
||||
}
|
||||
icon={FiCpu}
|
||||
onClick={() => setConfigModalActiveTab("llms")}
|
||||
/>
|
||||
|
||||
{!retrievalDisabled && (
|
||||
<div className="flex items-center space-x-3 mr-12 px-4 pb-2 ">
|
||||
<Popup
|
||||
removePadding
|
||||
content={(close) => (
|
||||
<AssistantsTab
|
||||
availableAssistants={availableAssistants}
|
||||
llmProviders={llmProviders}
|
||||
selectedAssistant={selectedAssistant}
|
||||
onSelect={(assistant) => {
|
||||
setSelectedAssistant(assistant);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
position="top"
|
||||
>
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="Filters"
|
||||
icon={FiFilter}
|
||||
onClick={() => setConfigModalActiveTab("filters")}
|
||||
flexPriority="shrink"
|
||||
name={
|
||||
selectedAssistant ? selectedAssistant.name : "Assistants"
|
||||
}
|
||||
Icon={AssistantsIconSkeleton as IconType}
|
||||
/>
|
||||
)}
|
||||
</Popup>
|
||||
|
||||
<Popup
|
||||
content={(close, ref) => (
|
||||
<LlmTab
|
||||
close={close}
|
||||
ref={ref}
|
||||
llmOverrideManager={llmOverrideManager}
|
||||
chatSessionId={chatSessionId}
|
||||
currentAssistant={selectedAssistant}
|
||||
/>
|
||||
)}
|
||||
position="top"
|
||||
>
|
||||
<ChatInputOption
|
||||
flexPriority="second"
|
||||
name={
|
||||
llmOverrideManager.llmOverride.modelName ||
|
||||
(selectedAssistant
|
||||
? selectedAssistant.llm_model_version_override || llmName
|
||||
: llmName)
|
||||
}
|
||||
Icon={CpuIconSkeleton}
|
||||
/>
|
||||
</Popup>
|
||||
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
icon={FiPlusCircle}
|
||||
Icon={FiPlusCircle}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
@@ -426,10 +486,10 @@ export function ChatInputBar({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FiSend
|
||||
size={18}
|
||||
className={`text-emphasis w-9 h-9 p-2 rounded-lg ${
|
||||
message ? "bg-blue-200" : ""
|
||||
<SendIcon
|
||||
size={28}
|
||||
className={`text-emphasis text-white p-1 rounded-full ${
|
||||
message ? "bg-background-800" : "bg-[#D7D7D7]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user