Compare commits

...

34 Commits

Author SHA1 Message Date
Yuhong Sun
4293543a6a k 2024-07-20 16:48:05 -07:00
Yuhong Sun
e95bfa0e0b Suffix Test (#1880) 2024-07-20 15:54:55 -07:00
Yuhong Sun
4848b5f1de Suffix Edits (#1878) 2024-07-20 13:59:14 -07:00
Yuhong Sun
7ba5c434fa Missing Comma (#1877) 2024-07-19 22:15:45 -07:00
Yuhong Sun
59bf5ba848 File Connector Metadata (#1876) 2024-07-19 20:45:18 -07:00
Weves
f66c33380c Improve widget README 2024-07-19 20:21:07 -07:00
Weves
115650ce9f Add example widget code 2024-07-19 20:14:52 -07:00
Weves
7aa3602fca Fix black 2024-07-19 18:55:09 -07:00
Weves
864c552a17 Fix UT 2024-07-19 18:55:09 -07:00
Brent Kwok
07b2ed3d8f Fix HTTP 422 error for api_inference_sample.py (#1868) 2024-07-19 18:54:43 -07:00
Yuhong Sun
38290057f2 Search Eval (#1873) 2024-07-19 16:48:58 -07:00
Weves
2344edf158 Change default login time to 7 days 2024-07-19 13:58:50 -07:00
versecafe
86d1804eb0 Add GPT-4o-Mini & fix a missing gpt-4o 2024-07-19 12:10:27 -07:00
pablodanswer
1ebae50d0c minor udpate 2024-07-19 10:53:28 -07:00
Weves
a9fbaa396c Stop building on every PR 2024-07-19 10:21:19 -07:00
pablodanswer
27d5f69427 udpate to headers (#1864) 2024-07-19 08:38:54 -07:00
pablodanswer
5d98421ae8 show "analysis" (#1863) 2024-07-18 18:18:36 -07:00
Kevin Shi
6b561b8ca9 Add config to skip zendesk article labels 2024-07-18 18:00:51 -07:00
pablodanswer
2dc7e64dd7 fix internet search icons / text + assistants tab (#1862) 2024-07-18 16:15:19 -07:00
Yuhong Sun
5230f7e22f Enforce Disable GenAI if set (#1860) 2024-07-18 13:25:55 -07:00
hagen-danswer
a595d43ae3 Fixed deleting toolcall by message 2024-07-18 12:52:28 -07:00
Yuhong Sun
ee561f42ff Cleaner Layout (#1857) 2024-07-18 11:13:16 -07:00
Yuhong Sun
f00b3d76b3 Touchup NoOp (#1856) 2024-07-18 08:44:27 -07:00
Yuhong Sun
e4984153c0 Touchups (#1855) 2024-07-17 23:47:10 -07:00
pablodanswer
87fadb07ea COMPLETE USER EXPERIENCE OVERHAUL (#1822) 2024-07-17 19:44:21 -07:00
pablodanswer
2b07c102f9 fix discourse connector rate limiting + topic fetching (#1820) 2024-07-17 14:57:40 -07:00
hagen-danswer
e93de602c3 Use SHA instead of branch and save more data (#1850) 2024-07-17 14:56:24 -07:00
hagen-danswer
1c77395503 Fixed llm_indices from document search api (#1853) 2024-07-17 14:52:49 -07:00
Victorivus
cdf6089b3e Fix bug XML files in chat (#1804) 2024-07-17 08:09:40 -07:00
pablodanswer
d01f46af2b fix search doc bug (#1851) 2024-07-16 15:27:04 -07:00
hagen-danswer
b83f435bb0 Catch dropped eval questions and added multiprocessing (#1849) 2024-07-16 12:33:02 -07:00
hagen-danswer
25b3dacaba Seperated model caching volumes (#1845) 2024-07-15 15:32:04 -07:00
hagen-danswer
a1e638a73d Improved eval logging and stability (#1843) 2024-07-15 14:58:45 -07:00
Yuhong Sun
bd1e0c5969 Add Enum File (#1842) 2024-07-15 09:13:27 -07:00
167 changed files with 14073 additions and 2634 deletions

View File

@@ -1,8 +1,6 @@
name: Build Backend Image on Merge Group
on:
pull_request:
branches: [ "main" ]
merge_group:
types: [checks_requested]

View File

@@ -1,8 +1,6 @@
name: Build Web Image on Merge Group
on:
pull_request:
branches: [ "main" ]
merge_group:
types: [checks_requested]

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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))

View File

@@ -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",

View File

@@ -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))

View File

@@ -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,

View File

@@ -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",

View File

@@ -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)

View File

@@ -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):

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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",

View File

@@ -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"""

View File

@@ -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(

View 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()

View File

@@ -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>""]

View File

@@ -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 [

View File

@@ -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
]

View 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

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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,

View File

@@ -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],

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -44,6 +44,7 @@ class BasicCreateChatMessageRequest(ChunkContext):
class SimpleDoc(BaseModel):
id: str
semantic_identifier: str
link: str | None
blurb: str

View File

@@ -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

View File

@@ -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",

View File

@@ -0,0 +1,6 @@
from enum import Enum
class EmbedTextType(str, Enum):
QUERY = "query"
PASSAGE = "passage"

View File

@@ -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

View 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"]

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -1 +1 @@
f1f2 1 1718910083.03085 wikipedia:en
f1f2 2 1721064549.902656 wikipedia:en

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

36
examples/widget/.gitignore vendored Normal file
View 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
View 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.

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

5933
examples/widget/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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>
);
}

View 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>
);
}

View 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>
);
};

View 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;

View 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"]
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -28,7 +28,7 @@ export default async function Page() {
}
return (
<div>
<div className="w-full">
<BackButton />
<AdminPageTitle
title="Create a New Assistant"

View File

@@ -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

View File

@@ -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 />

View 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"

View 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"

View 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);

View File

@@ -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 />

View File

@@ -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 />

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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>

View File

@@ -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"
/>

View 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>
);
}

View File

@@ -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;

View 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}
/>
)}
/>
);
}

View File

@@ -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>
</>
);

View 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}
/>
)}
/>
);
}

View File

@@ -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>
</>
);

View File

@@ -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}

View File

@@ -16,7 +16,7 @@ export function ChatBanner() {
className={`
z-[39]
h-[30px]
bg-background-custom-header
bg-background-100
shadow-sm
m-2
rounded

View File

@@ -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

View 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}
/>
)}
/>
);
}

View File

@@ -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">
{/*

View File

@@ -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

View File

@@ -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: &quot;
<i>
{
selectedDocuments[
selectedDocuments.length - 1
].semantic_identifier
}
</i>
&quot;
</>
)}
</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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View 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;

View File

@@ -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