Updated chat flow (#3244)

* proper no assistant typing + no assistant modal

* updated chat flow

* k

* updates

* update

* k

* clean up

* fix mystery reorg

* cleanup

* update scroll

* default

* update logs

* push fade

* scroll nit

* finalize tags

* updates

* k

* various updates

* viewport height update

* source types update

* clean up unused components

* minor cleanup

* cleanup complete

* finalize changes

* badge up

* update filters

* small nit

* k

* k

* address comments

* quick unification of icons

* minor date range clarity

* minor nit

* k

* update sidebar line

* update for all screen sizes

* k

* k

* k

* k

* rm shs

* fix memoization

* fix memoization

* slack chat

* k

* k

* build org
This commit is contained in:
pablodanswer 2024-12-01 17:58:28 -08:00 committed by GitHub
parent 3432d932d1
commit de66f7adb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 3441 additions and 3458 deletions

View File

@ -0,0 +1,27 @@
"""add auto scroll to user model
Revision ID: a8c2065484e6
Revises: abe7378b8217
Create Date: 2024-11-22 17:34:09.690295
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a8c2065484e6"
down_revision = "abe7378b8217"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user",
sa.Column("auto_scroll", sa.Boolean(), nullable=True, server_default=None),
)
def downgrade() -> None:
op.drop_column("user", "auto_scroll")

View File

@ -23,7 +23,9 @@ def load_no_auth_user_preferences(store: KeyValueStore) -> UserPreferences:
)
return UserPreferences(**preferences_data)
except KvKeyNotFoundError:
return UserPreferences(chosen_assistants=None, default_model=None)
return UserPreferences(
chosen_assistants=None, default_model=None, auto_scroll=True
)
def fetch_no_auth_user(store: KeyValueStore) -> UserInfo:

View File

@ -605,6 +605,7 @@ def stream_chat_message_objects(
additional_headers=custom_tool_additional_headers,
),
)
tools: list[Tool] = []
for tool_list in tool_dict.values():
tools.extend(tool_list)

View File

@ -126,6 +126,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
# if specified, controls the assistants that are shown to the user + their order
# if not specified, all assistants are shown
auto_scroll: Mapped[bool] = mapped_column(Boolean, default=True)
chosen_assistants: Mapped[list[int] | None] = mapped_column(
postgresql.JSONB(), nullable=True, default=None
)

View File

@ -5,7 +5,7 @@ personas:
# this is for DanswerBot to use when tagged in a non-configured channel
# Careful setting specific IDs, this won't autoincrement the next ID value for postgres
- id: 0
name: "Knowledge"
name: "Search"
description: >
Assistant with access to documents from your Connected Sources.
# Default Prompt objects attached to the persona, see prompts.yaml

View File

@ -45,6 +45,7 @@ class UserPreferences(BaseModel):
visible_assistants: list[int] = []
recent_assistants: list[int] | None = None
default_model: str | None = None
auto_scroll: bool | None = None
class UserInfo(BaseModel):
@ -79,6 +80,7 @@ class UserInfo(BaseModel):
role=user.role,
preferences=(
UserPreferences(
auto_scroll=user.auto_scroll,
chosen_assistants=user.chosen_assistants,
default_model=user.default_model,
hidden_assistants=user.hidden_assistants,
@ -128,6 +130,10 @@ class HiddenUpdateRequest(BaseModel):
hidden: bool
class AutoScrollRequest(BaseModel):
auto_scroll: bool | None
class SlackBotCreationRequest(BaseModel):
name: str
enabled: bool

View File

@ -52,6 +52,7 @@ from danswer.db.users import list_users
from danswer.db.users import validate_user_role_update
from danswer.key_value_store.factory import get_kv_store
from danswer.server.manage.models import AllUsersResponse
from danswer.server.manage.models import AutoScrollRequest
from danswer.server.manage.models import UserByEmail
from danswer.server.manage.models import UserInfo
from danswer.server.manage.models import UserPreferences
@ -497,7 +498,6 @@ def verify_user_logged_in(
return fetch_no_auth_user(store)
raise BasicAuthenticationError(detail="User Not Authenticated")
if user.oidc_expiry and user.oidc_expiry < datetime.now(timezone.utc):
raise BasicAuthenticationError(
detail="Access denied. User's OIDC token has expired.",
@ -581,6 +581,30 @@ def update_user_recent_assistants(
db_session.commit()
@router.patch("/auto-scroll")
def update_user_auto_scroll(
request: AutoScrollRequest,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
if user is None:
if AUTH_TYPE == AuthType.DISABLED:
store = get_kv_store()
no_auth_user = fetch_no_auth_user(store)
no_auth_user.preferences.auto_scroll = request.auto_scroll
set_no_auth_user_preferences(store, no_auth_user.preferences)
return
else:
raise RuntimeError("This should never happen")
db_session.execute(
update(User)
.where(User.id == user.id) # type: ignore
.values(auto_scroll=request.auto_scroll)
)
db_session.commit()
@router.patch("/user/default-model")
def update_user_default_model(
request: ChosenDefaultModelRequest,

View File

@ -79,6 +79,7 @@ class CreateChatMessageRequest(ChunkContext):
message: str
# Files that we should attach to this message
file_descriptors: list[FileDescriptor]
# If no prompt provided, uses the largest prompt of the chat session
# but really this should be explicitly specified, only in the simplified APIs is this inferred
# Use prompt_id 0 to use the system default prompt which is Answer-Question

View File

@ -2,7 +2,6 @@ from typing import cast
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
@ -38,10 +37,6 @@ basic_router = APIRouter(prefix="/settings")
def put_settings(
settings: Settings, _: User | None = Depends(current_admin_user)
) -> None:
try:
settings.check_validity()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
store_settings(settings)

View File

@ -41,33 +41,10 @@ class Notification(BaseModel):
class Settings(BaseModel):
"""General settings"""
chat_page_enabled: bool = True
search_page_enabled: bool = True
default_page: PageType = PageType.SEARCH
maximum_chat_retention_days: int | None = None
gpu_enabled: bool | None = None
product_gating: GatingType = GatingType.NONE
def check_validity(self) -> None:
chat_page_enabled = self.chat_page_enabled
search_page_enabled = self.search_page_enabled
default_page = self.default_page
if chat_page_enabled is False and search_page_enabled is False:
raise ValueError(
"One of `search_page_enabled` and `chat_page_enabled` must be True."
)
if default_page == PageType.CHAT and chat_page_enabled is False:
raise ValueError(
"The default page cannot be 'chat' if the chat page is disabled."
)
if default_page == PageType.SEARCH and search_page_enabled is False:
raise ValueError(
"The default page cannot be 'search' if the search page is disabled."
)
class UserSettings(Settings):
notifications: list[Notification]

View File

@ -113,10 +113,6 @@ async def refresh_access_token(
def put_settings(
settings: EnterpriseSettings, _: User | None = Depends(current_admin_user)
) -> None:
try:
settings.check_validity()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
store_settings(settings)

View File

@ -157,7 +157,6 @@ def _seed_personas(db_session: Session, personas: list[CreatePersonaRequest]) ->
def _seed_settings(settings: Settings) -> None:
logger.notice("Seeding Settings")
try:
settings.check_validity()
store_base_settings(settings)
logger.notice("Successfully seeded Settings")
except ValueError as e:

9
web/@types/favicon-fetch.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module "favicon-fetch" {
interface FaviconFetchOptions {
uri: string;
}
function faviconFetch(options: FaviconFetchOptions): string | null;
export default faviconFetch;
}

1007
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,11 +17,13 @@
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1",
"@phosphor-icons/react": "^2.0.8",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@sentry/nextjs": "^8.34.0",
@ -37,6 +39,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"favicon-fetch": "^1.0.0",
"formik": "^2.2.9",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
@ -67,6 +70,7 @@
"tailwindcss-animate": "^1.0.7",
"typescript": "5.0.3",
"uuid": "^9.0.1",
"vaul": "^1.1.1",
"yup": "^1.4.0"
},
"devDependencies": {

View File

@ -83,7 +83,7 @@ const EditRow = ({
</div>
</TooltipTrigger>
{!documentSet.is_up_to_date && (
<TooltipContent maxWidth="max-w-sm">
<TooltipContent width="max-w-sm">
<div className="flex break-words break-keep whitespace-pre-wrap items-start">
<InfoIcon className="mr-2 mt-0.5" />
Cannot update while syncing! Wait for the sync to finish, then

View File

@ -175,29 +175,6 @@ export function SettingsForm() {
{ fieldName, newValue: checked },
];
// If we're disabling a page, check if we need to update the default page
if (
!checked &&
(fieldName === "search_page_enabled" || fieldName === "chat_page_enabled")
) {
const otherPageField =
fieldName === "search_page_enabled"
? "chat_page_enabled"
: "search_page_enabled";
const otherPageEnabled = settings && settings[otherPageField];
if (
otherPageEnabled &&
settings?.default_page ===
(fieldName === "search_page_enabled" ? "search" : "chat")
) {
updates.push({
fieldName: "default_page",
newValue: fieldName === "search_page_enabled" ? "chat" : "search",
});
}
}
updateSettingField(updates);
}
@ -218,42 +195,17 @@ export function SettingsForm() {
return (
<div>
{popup}
<Title className="mb-4">Page Visibility</Title>
<Title className="mb-4">Workspace Settings</Title>
<Checkbox
label="Search Page Enabled?"
sublabel="If set, then the 'Search' page will be accessible to all users and will show up as an option on the top navbar. If unset, then this page will not be available."
checked={settings.search_page_enabled}
label="Auto-scroll"
sublabel="If set, the chat window will automatically scroll to the bottom as new lines of text are generated by the AI model."
checked={settings.auto_scroll}
onChange={(e) =>
handleToggleSettingsField("search_page_enabled", e.target.checked)
handleToggleSettingsField("auto_scroll", e.target.checked)
}
/>
<Checkbox
label="Chat Page Enabled?"
sublabel="If set, then the 'Chat' page will be accessible to all users and will show up as an option on the top navbar. If unset, then this page will not be available."
checked={settings.chat_page_enabled}
onChange={(e) =>
handleToggleSettingsField("chat_page_enabled", e.target.checked)
}
/>
<Selector
label="Default Page"
subtext="The page that users will be redirected to after logging in. Can only be set to a page that is enabled."
options={[
{ value: "search", name: "Search" },
{ value: "chat", name: "Chat" },
]}
selected={settings.default_page}
onSelect={(value) => {
value &&
updateSettingField([
{ fieldName: "default_page", newValue: value },
]);
}}
/>
{isEnterpriseEnabled && (
<>
<Title className="mb-4">Chat Settings</Title>

View File

@ -5,14 +5,12 @@ export enum GatingType {
}
export interface Settings {
chat_page_enabled: boolean;
search_page_enabled: boolean;
default_page: "search" | "chat";
maximum_chat_retention_days: number | null;
notifications: Notification[];
needs_reindexing: boolean;
gpu_enabled: boolean;
product_gating: GatingType;
auto_scroll: boolean;
}
export enum NotificationType {
@ -54,6 +52,7 @@ export interface EnterpriseSettings {
custom_popup_header: string | null;
custom_popup_content: string | null;
enable_consent_screen: boolean | null;
auto_scroll: boolean;
}
export interface CombinedSettings {

View File

@ -8,7 +8,6 @@ import {
ChatFileType,
ChatSession,
ChatSessionSharedStatus,
DocumentsResponse,
FileDescriptor,
FileChatDisplay,
Message,
@ -60,7 +59,7 @@ import { useDocumentSelection } from "./useDocumentSelection";
import { LlmOverride, useFilters, useLlmOverride } from "@/lib/hooks";
import { computeAvailableFilters } from "@/lib/filters";
import { ChatState, FeedbackType, RegenerationState } from "./types";
import { DocumentSidebar } from "./documentSidebar/DocumentSidebar";
import { ChatFilters } from "./documentSidebar/ChatFilters";
import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader";
import { FeedbackModal } from "./modal/FeedbackModal";
import { ShareChatSessionModal } from "./modal/ShareChatSessionModal";
@ -71,6 +70,7 @@ import { StarterMessages } from "../../components/assistants/StarterMessage";
import {
AnswerPiecePacket,
DanswerDocument,
FinalContextDocs,
StreamStopInfo,
StreamStopReason,
} from "@/lib/search/interfaces";
@ -105,14 +105,9 @@ import BlurBackground from "./shared_chat_search/BlurBackground";
import { NoAssistantModal } from "@/components/modals/NoAssistantModal";
import { useAssistants } from "@/components/context/AssistantsContext";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import AssistantBanner from "../../components/assistants/AssistantBanner";
import AssistantSelector from "@/components/chat_search/AssistantSelector";
import { Modal } from "@/components/Modal";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@ -132,8 +127,9 @@ export function ChatPage({
const {
chatSessions,
availableSources,
availableDocumentSets,
ccPairs,
tags,
documentSets,
llmProviders,
folders,
openedFolders,
@ -142,6 +138,36 @@ export function ChatPage({
shouldShowWelcomeModal,
refreshChatSessions,
} = useChatContext();
function useScreenSize() {
const [screenSize, setScreenSize] = useState({
width: typeof window !== "undefined" ? window.innerWidth : 0,
height: typeof window !== "undefined" ? window.innerHeight : 0,
});
useEffect(() => {
const handleResize = () => {
setScreenSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return screenSize;
}
const { height: screenHeight } = useScreenSize();
const getContainerHeight = () => {
if (autoScrollEnabled) return undefined;
if (screenHeight < 600) return "20vh";
if (screenHeight < 1200) return "30vh";
return "40vh";
};
// handle redirect if chat page is disabled
// NOTE: this must be done here, in a client component since
@ -149,9 +175,11 @@ export function ChatPage({
// available in server-side components
const settings = useContext(SettingsContext);
const enterpriseSettings = settings?.enterpriseSettings;
if (settings?.settings?.chat_page_enabled === false) {
router.push("/search");
}
const [documentSidebarToggled, setDocumentSidebarToggled] = useState(false);
const [filtersToggled, setFiltersToggled] = useState(false);
const [userSettingsToggled, setUserSettingsToggled] = useState(false);
const { assistants: availableAssistants, finalAssistants } = useAssistants();
@ -159,16 +187,13 @@ export function ChatPage({
!shouldShowWelcomeModal
);
const { user, isAdmin, isLoadingUser, refreshUser } = useUser();
const { user, isAdmin, isLoadingUser } = useUser();
const slackChatId = searchParams.get("slackChatId");
const existingChatIdRaw = searchParams.get("chatId");
const [sendOnLoad, setSendOnLoad] = useState<string | null>(
searchParams.get(SEARCH_PARAM_NAMES.SEND_ON_LOAD)
);
const currentPersonaId = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID);
const modelVersionFromSearchParams = searchParams.get(
SEARCH_PARAM_NAMES.STRUCTURED_MODEL
);
@ -261,7 +286,7 @@ export function ChatPage({
refreshRecentAssistants,
} = useAssistants();
const liveAssistant =
const liveAssistant: Persona | undefined =
alternativeAssistant ||
selectedAssistant ||
recentAssistants[0] ||
@ -269,8 +294,20 @@ export function ChatPage({
availableAssistants[0];
const noAssistants = liveAssistant == null || liveAssistant == undefined;
const availableSources = ccPairs.map((ccPair) => ccPair.source);
const [finalAvailableSources, finalAvailableDocumentSets] =
computeAvailableFilters({
selectedPersona: availableAssistants.find(
(assistant) => assistant.id === liveAssistant?.id
),
availableSources: availableSources,
availableDocumentSets: documentSets,
});
// always set the model override for the chat session, when an assistant, llm provider, or user preference exists
useEffect(() => {
if (noAssistants) return;
const personaDefault = getLLMProviderOverrideForPersona(
liveAssistant,
llmProviders
@ -357,9 +394,7 @@ export function ChatPage({
textAreaRef.current?.focus();
// only clear things if we're going from one chat session to another
const isChatSessionSwitch =
chatSessionIdRef.current !== null &&
existingChatSessionId !== priorChatSessionId;
const isChatSessionSwitch = existingChatSessionId !== priorChatSessionId;
if (isChatSessionSwitch) {
// de-select documents
clearSelectedDocuments();
@ -449,9 +484,9 @@ export function ChatPage({
}
if (shouldScrollToBottom) {
if (!hasPerformedInitialScroll) {
if (!hasPerformedInitialScroll && autoScrollEnabled) {
clientScrollToBottom();
} else if (isChatSessionSwitch) {
} else if (isChatSessionSwitch && autoScrollEnabled) {
clientScrollToBottom(true);
}
}
@ -759,7 +794,7 @@ export function ChatPage({
useEffect(() => {
async function fetchMaxTokens() {
const response = await fetch(
`/api/chat/max-selected-document-tokens?persona_id=${liveAssistant.id}`
`/api/chat/max-selected-document-tokens?persona_id=${liveAssistant?.id}`
);
if (response.ok) {
const maxTokens = (await response.json()).max_tokens as number;
@ -833,11 +868,13 @@ export function ChatPage({
0
)}px`;
scrollableDivRef?.current.scrollBy({
left: 0,
top: Math.max(heightDifference, 0),
behavior: "smooth",
});
if (autoScrollEnabled) {
scrollableDivRef?.current.scrollBy({
left: 0,
top: Math.max(heightDifference, 0),
behavior: "smooth",
});
}
}
previousHeight.current = newHeight;
}
@ -884,6 +921,7 @@ export function ChatPage({
endDivRef.current.scrollIntoView({
behavior: fast ? "auto" : "smooth",
});
setHasPerformedInitialScroll(true);
}
}, 50);
@ -1035,7 +1073,9 @@ export function ChatPage({
}
setAlternativeGeneratingAssistant(alternativeAssistantOverride);
clientScrollToBottom();
let currChatSessionId: string;
const isNewSession = chatSessionIdRef.current === null;
const searchParamBasedChatSessionName =
@ -1281,8 +1321,8 @@ export function ChatPage({
if (Object.hasOwn(packet, "answer_piece")) {
answer += (packet as AnswerPiecePacket).answer_piece;
} else if (Object.hasOwn(packet, "top_documents")) {
documents = (packet as DocumentsResponse).top_documents;
} else if (Object.hasOwn(packet, "final_context_docs")) {
documents = (packet as FinalContextDocs).final_context_docs;
retrievalType = RetrievalType.Search;
if (documents && documents.length > 0) {
// point to the latest message (we don't know the messageId yet, which is why
@ -1379,8 +1419,7 @@ export function ChatPage({
type: error ? "error" : "assistant",
retrievalType,
query: finalMessage?.rephrased_query || query,
documents:
finalMessage?.context_docs?.top_documents || documents,
documents: documents,
citations: finalMessage?.citations || {},
files: finalMessage?.files || aiMessageImages || [],
toolCall: finalMessage?.tool_call || toolCall,
@ -1599,6 +1638,11 @@ export function ChatPage({
mobile: settings?.isMobile,
});
const autoScrollEnabled =
user?.preferences?.auto_scroll == null
? settings?.enterpriseSettings?.auto_scroll || false
: user?.preferences?.auto_scroll!;
useScrollonStream({
chatState: currentSessionChatState,
scrollableDivRef,
@ -1607,6 +1651,7 @@ export function ChatPage({
debounceNumber,
waitForScrollRef,
mobile: settings?.isMobile,
enableAutoScroll: autoScrollEnabled,
});
// Virtualization + Scrolling related effects and functions
@ -1756,6 +1801,13 @@ export function ChatPage({
liveAssistant
);
});
useEffect(() => {
if (!retrievalEnabled) {
setDocumentSidebarToggled(false);
}
}, [retrievalEnabled]);
const [stackTraceModalContent, setStackTraceModalContent] = useState<
string | null
>(null);
@ -1764,58 +1816,6 @@ export function ChatPage({
const [settingsToggled, setSettingsToggled] = useState(false);
const currentPersona = alternativeAssistant || liveAssistant;
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);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
const [sharedChatSession, setSharedChatSession] =
useState<ChatSession | null>();
const [deletingChatSession, setDeletingChatSession] =
useState<ChatSession | null>();
const showDeleteModal = (chatSession: ChatSession) => {
setDeletingChatSession(chatSession);
};
const showShareModal = (chatSession: ChatSession) => {
setSharedChatSession(chatSession);
};
const [documentSelection, setDocumentSelection] = useState(false);
const toggleDocumentSelectionAspects = () => {
setDocumentSelection((documentSelection) => !documentSelection);
setShowDocSidebar(false);
};
interface RegenerationRequest {
messageId: number;
parentMessage: Message;
}
function createRegenerator(regenerationRequest: RegenerationRequest) {
// Returns new function that only needs `modelOverRide` to be specified when called
return async function (modelOverRide: LlmOverride) {
return await onSubmit({
modelOverRide,
messageIdToResend: regenerationRequest.parentMessage.messageId,
regenerationRequest,
});
};
}
useEffect(() => {
const handleSlackChatRedirect = async () => {
if (!slackChatId) return;
@ -1851,18 +1851,94 @@ export function ChatPage({
handleSlackChatRedirect();
}, [searchParams, router]);
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);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
const [sharedChatSession, setSharedChatSession] =
useState<ChatSession | null>();
const [deletingChatSession, setDeletingChatSession] =
useState<ChatSession | null>();
const showDeleteModal = (chatSession: ChatSession) => {
setDeletingChatSession(chatSession);
};
const showShareModal = (chatSession: ChatSession) => {
setSharedChatSession(chatSession);
};
const [documentSelection, setDocumentSelection] = useState(false);
// const toggleDocumentSelectionAspects = () => {
// setDocumentSelection((documentSelection) => !documentSelection);
// setShowDocSidebar(false);
// };
const toggleDocumentSidebar = () => {
if (!documentSidebarToggled) {
setFiltersToggled(false);
setDocumentSidebarToggled(true);
} else if (!filtersToggled) {
setDocumentSidebarToggled(false);
} else {
setFiltersToggled(false);
}
};
const toggleFilters = () => {
if (!documentSidebarToggled) {
setFiltersToggled(true);
setDocumentSidebarToggled(true);
} else if (filtersToggled) {
setDocumentSidebarToggled(false);
} else {
setFiltersToggled(true);
}
};
interface RegenerationRequest {
messageId: number;
parentMessage: Message;
}
function createRegenerator(regenerationRequest: RegenerationRequest) {
// Returns new function that only needs `modelOverRide` to be specified when called
return async function (modelOverRide: LlmOverride) {
return await onSubmit({
modelOverRide,
messageIdToResend: regenerationRequest.parentMessage.messageId,
regenerationRequest,
});
};
}
if (noAssistants)
return (
<>
<HealthCheckBanner />
<NoAssistantModal isAdmin={isAdmin} />
</>
);
return (
<>
<HealthCheckBanner />
{showApiKeyModal && !shouldShowWelcomeModal ? (
{showApiKeyModal && !shouldShowWelcomeModal && (
<ApiKeyModal
hide={() => setShowApiKeyModal(false)}
setPopup={setPopup}
/>
) : (
noAssistants && <NoAssistantModal isAdmin={isAdmin} />
)}
{/* ChatPopup is a custom popup that displays a admin-specified message on initial user visit.
@ -1886,16 +1962,46 @@ export function ChatPage({
/>
)}
{settingsToggled && (
{(settingsToggled || userSettingsToggled) && (
<SetDefaultModelModal
setPopup={setPopup}
setLlmOverride={llmOverrideManager.setGlobalDefault}
defaultModel={user?.preferences.default_model!}
llmProviders={llmProviders}
onClose={() => setSettingsToggled(false)}
onClose={() => {
setUserSettingsToggled(false);
setSettingsToggled(false);
}}
/>
)}
{retrievalEnabled && documentSidebarToggled && settings?.isMobile && (
<div className="md:hidden">
<Modal noPadding noScroll>
<ChatFilters
modal={true}
filterManager={filterManager}
ccPairs={ccPairs}
tags={tags}
documentSets={documentSets}
ref={innerSidebarElementRef}
showFilters={filtersToggled}
closeSidebar={() => {
setDocumentSidebarToggled(false);
}}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={toggleDocumentSelection}
clearSelectedDocuments={clearSelectedDocuments}
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
initialWidth={400}
isOpen={true}
/>
</Modal>
</div>
)}
{deletingChatSession && (
<DeleteEntityModal
entityType="chat"
@ -1996,6 +2102,50 @@ export function ChatPage({
</div>
</div>
</div>
{!settings?.isMobile && retrievalEnabled && (
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
fixed
right-0
z-[1000]
bg-background
h-screen
transition-all
bg-opacity-80
duration-300
ease-in-out
bg-transparent
transition-all
bg-opacity-80
duration-300
ease-in-out
h-full
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
>
<ChatFilters
modal={false}
filterManager={filterManager}
ccPairs={ccPairs}
tags={tags}
documentSets={documentSets}
ref={innerSidebarElementRef}
showFilters={filtersToggled}
closeSidebar={() => setDocumentSidebarToggled(false)}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={toggleDocumentSelection}
clearSelectedDocuments={clearSelectedDocuments}
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
initialWidth={400}
isOpen={documentSidebarToggled}
/>
</div>
)}
<BlurBackground
visible={!untoggled && (showDocSidebar || toggledSidebar)}
@ -2005,9 +2155,12 @@ export function ChatPage({
ref={masterFlexboxRef}
className="flex h-full w-full overflow-x-hidden"
>
<div className="flex h-full flex-col w-full">
<div className="flex h-full relative px-2 flex-col w-full">
{liveAssistant && (
<FunctionalHeader
toggleUserSettings={() => setUserSettingsToggled(true)}
liveAssistant={liveAssistant}
onAssistantChange={onAssistantChange}
sidebarToggled={toggledSidebar}
reset={() => setMessage("")}
page="chat"
@ -2018,6 +2171,8 @@ export function ChatPage({
}
toggleSidebar={toggleSidebar}
currentChatSession={selectedChatSession}
documentSidebarToggled={documentSidebarToggled}
llmOverrideManager={llmOverrideManager}
/>
)}
@ -2039,7 +2194,7 @@ export function ChatPage({
duration-300
ease-in-out
h-full
${toggledSidebar ? "w-[250px]" : "w-[0px]"}
${toggledSidebar ? "w-[200px]" : "w-[0px]"}
`}
></div>
)}
@ -2049,9 +2204,55 @@ export function ChatPage({
{...getRootProps()}
>
<div
className={`w-full h-full flex flex-col default-scrollbar overflow-y-auto overflow-x-hidden relative`}
className={`w-full h-[calc(100vh-160px)] flex flex-col default-scrollbar overflow-y-auto overflow-x-hidden relative`}
ref={scrollableDivRef}
>
{liveAssistant && onAssistantChange && (
<div className="z-20 fixed top-4 pointer-events-none left-0 w-full flex justify-center overflow-visible">
{!settings?.isMobile && (
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
overflow-y-hidden
transition-all
pointer-events-none
duration-300
ease-in-out
h-full
${toggledSidebar ? "w-[200px]" : "w-[0px]"}
`}
></div>
)}
<AssistantSelector
isMobile={settings?.isMobile!}
liveAssistant={liveAssistant}
onAssistantChange={onAssistantChange}
llmOverrideManager={llmOverrideManager}
/>
{!settings?.isMobile && (
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
overflow-y-hidden
transition-all
duration-300
ease-in-out
h-full
pointer-events-none
${
documentSidebarToggled && retrievalEnabled
? "w-[400px]"
: "w-[0px]"
}
`}
></div>
)}
</div>
)}
{/* ChatBanner is a custom banner that displays a admin-specified message at
the top of the chat page. Oly used in the EE version of the app. */}
@ -2059,7 +2260,7 @@ export function ChatPage({
!isFetchingChatMessages &&
currentSessionChatState == "input" &&
!loadingError && (
<div className="h-full mt-12 flex flex-col justify-center items-center">
<div className="h-full w-[95%] mx-auto mt-12 flex flex-col justify-center items-center">
<ChatIntro selectedPersona={liveAssistant} />
<StarterMessages
@ -2081,6 +2282,7 @@ export function ChatPage({
Recent Assistants
</div>
<AssistantBanner
mobile={settings?.isMobile}
recentAssistants={recentAssistants}
liveAssistant={liveAssistant}
allAssistants={allAssistants}
@ -2222,6 +2424,14 @@ export function ChatPage({
}
>
<AIMessage
index={i}
selectedMessageForDocDisplay={
selectedMessageForDocDisplay
}
documentSelectionToggled={
documentSidebarToggled &&
!filtersToggled
}
continueGenerating={
i == messageHistory.length - 1 &&
currentCanContinue()
@ -2258,9 +2468,19 @@ export function ChatPage({
}}
isActive={messageHistory.length - 1 == i}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={
toggleDocumentSelectionAspects
}
toggleDocumentSelection={() => {
if (
!documentSidebarToggled ||
(documentSidebarToggled &&
selectedMessageForDocDisplay ===
message.messageId)
) {
toggleDocumentSidebar();
}
setSelectedMessageForDocDisplay(
message.messageId
);
}}
docs={message.documents}
currentPersona={liveAssistant}
alternativeAssistant={
@ -2268,7 +2488,6 @@ export function ChatPage({
}
messageId={message.messageId}
content={message.message}
// content={message.message}
files={message.files}
query={
messageHistory[i]?.query || undefined
@ -2454,6 +2673,15 @@ export function ChatPage({
/>
</div>
)}
{messageHistory.length > 0 && (
<div
style={{
height: !autoScrollEnabled
? getContainerHeight()
: undefined,
}}
/>
)}
{/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
<div ref={endPaddingRef} className="h-[95px]" />
@ -2477,6 +2705,15 @@ export function ChatPage({
</div>
)}
<ChatInputBar
removeDocs={() => {
clearSelectedDocuments();
}}
removeFilters={() => {
filterManager.setSelectedSources([]);
filterManager.setSelectedTags([]);
filterManager.setSelectedDocumentSets([]);
setDocumentSidebarToggled(false);
}}
showConfigureAPIKey={() =>
setShowApiKeyModal(true)
}
@ -2499,6 +2736,9 @@ export function ChatPage({
llmOverrideManager={llmOverrideManager}
files={currentMessageFiles}
setFiles={setCurrentMessageFiles}
toggleFilters={
retrievalEnabled ? toggleFilters : undefined
}
handleFileUpload={handleImageUpload}
textAreaRef={textAreaRef}
chatSessionId={chatSessionIdRef.current!}
@ -2529,6 +2769,23 @@ export function ChatPage({
</div>
</div>
</div>
{!settings?.isMobile && (
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
overflow-y-hidden
transition-all
duration-300
ease-in-out
${
documentSidebarToggled && retrievalEnabled
? "w-[400px]"
: "w-[0px]"
}
`}
></div>
)}
</div>
)}
</Dropzone>
@ -2537,7 +2794,11 @@ export function ChatPage({
<div
style={{ transition: "width 0.30s ease-out" }}
className={`flex-none bg-transparent transition-all bg-opacity-80 duration-300 epase-in-out h-full
${toggledSidebar ? "w-[250px] " : "w-[0px]"}`}
${
toggledSidebar && !settings?.isMobile
? "w-[250px] "
: "w-[0px]"
}`}
/>
<div className="my-auto">
<DanswerInitializingLoader />
@ -2548,20 +2809,8 @@ export function ChatPage({
</div>
<FixedLogo backgroundToggled={toggledSidebar || showDocSidebar} />
</div>
{/* Right Sidebar - DocumentSidebar */}
</div>
<DocumentSidebar
initialWidth={350}
ref={innerSidebarElementRef}
closeSidebar={() => setDocumentSelection(false)}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={toggleDocumentSelection}
clearSelectedDocuments={clearSelectedDocuments}
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
isLoading={isFetchingChatMessages}
isOpen={documentSelection}
/>
</>
);
}

View File

@ -1,133 +1,117 @@
import { HoverPopup } from "@/components/HoverPopup";
import { SourceIcon } from "@/components/SourceIcon";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { DanswerDocument } from "@/lib/search/interfaces";
import { FiInfo, FiRadio } from "react-icons/fi";
import { FiTag } from "react-icons/fi";
import { DocumentSelector } from "./DocumentSelector";
import {
DocumentMetadataBlock,
buildDocumentSummaryDisplay,
} from "@/components/search/DocumentDisplay";
import { InternetSearchIcon } from "@/components/InternetSearchIcon";
import { buildDocumentSummaryDisplay } from "@/components/search/DocumentDisplay";
import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge";
import { MetadataBadge } from "@/components/MetadataBadge";
import { WebResultIcon } from "@/components/WebResultIcon";
interface DocumentDisplayProps {
document: DanswerDocument;
queryEventId: number | null;
isAIPick: boolean;
modal?: boolean;
isSelected: boolean;
handleSelect: (documentId: string) => void;
setPopup: (popupSpec: PopupSpec | null) => void;
tokenLimitReached: boolean;
}
export function DocumentMetadataBlock({
modal,
document,
}: {
modal?: boolean;
document: DanswerDocument;
}) {
const MAX_METADATA_ITEMS = 3;
const metadataEntries = Object.entries(document.metadata);
return (
<div className="flex items-center overflow-hidden">
{document.updated_at && (
<DocumentUpdatedAtBadge updatedAt={document.updated_at} modal={modal} />
)}
{metadataEntries.length > 0 && (
<>
<div className="mx-1 h-4 border-l border-border" />
<div className="flex items-center overflow-hidden">
{metadataEntries
.slice(0, MAX_METADATA_ITEMS)
.map(([key, value], index) => (
<MetadataBadge
key={index}
icon={FiTag}
value={`${key}=${value}`}
/>
))}
{metadataEntries.length > MAX_METADATA_ITEMS && (
<span className="ml-1 text-xs text-gray-500">...</span>
)}
</div>
</>
)}
</div>
);
}
export function ChatDocumentDisplay({
document,
queryEventId,
isAIPick,
modal,
isSelected,
handleSelect,
setPopup,
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={`p-2 w-[325px] justify-start rounded-md ${
isSelected ? "bg-background-200" : "bg-background-125"
} text-sm mx-3`}
>
<div className="flex relative justify-start overflow-y-visible">
<div className={`opacity-100 ${modal ? "w-[90vw]" : "w-full"}`}>
<div
className={`flex relative flex-col gap-0.5 rounded-xl mx-2 my-1 ${
isSelected ? "bg-gray-200" : "hover:bg-background-125"
}`}
>
<a
href={document.link}
target="_blank"
className={
"rounded-lg flex font-bold flex-shrink truncate" +
(document.link ? "" : "pointer-events-none")
}
rel="noreferrer"
rel="noopener noreferrer"
className="cursor-pointer flex flex-col px-2 py-1.5"
>
{isInternet ? (
<InternetSearchIcon url={document.link} />
) : (
<SourceIcon sourceType={document.source_type} iconSize={18} />
)}
<p className="overflow-hidden text-left text-ellipsis mx-2 my-auto text-sm">
{document.semantic_identifier || document.document_id}
</p>
</a>
{document.score !== null && (
<div className="my-auto">
{isAIPick && (
<div className="w-4 h-4 my-auto mr-1 flex flex-col">
<HoverPopup
mainContent={<FiRadio className="text-gray-500 my-auto" />}
popupContent={
<div className="text-xs text-gray-300 w-36 flex">
<div className="flex mx-auto">
<div className="w-3 h-3 flex flex-col my-auto mr-1">
<FiInfo className="my-auto" />
</div>
<div className="my-auto">The AI liked this doc!</div>
</div>
</div>
}
direction="bottom"
style="dark"
/>
</div>
<div className="line-clamp-1 mb-1 flex h-6 items-center gap-2 text-xs">
{document.is_internet || document.source_type === "web" ? (
<WebResultIcon url={document.link} />
) : (
<SourceIcon sourceType={document.source_type} iconSize={18} />
)}
<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 className="line-clamp-1 text-text-900 text-sm font-semibold">
{(document.semantic_identifier || document.document_id).length >
(modal ? 30 : 40)
? `${(document.semantic_identifier || document.document_id)
.slice(0, modal ? 30 : 40)
.trim()}...`
: document.semantic_identifier || document.document_id}
</div>
</div>
)}
{!isInternet && (
<DocumentSelector
isSelected={isSelected}
handleSelect={() => handleSelect(document.document_id)}
isDisabled={tokenLimitReached && !isSelected}
/>
)}
</div>
<div>
<div className="mt-1">
<DocumentMetadataBlock document={document} />
</div>
</div>
<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">
{/*
// TODO: find a way to include this
{queryEventId && (
<DocumentFeedbackBlock
documentId={document.document_id}
queryId={queryEventId}
setPopup={setPopup}
/>
)} */}
<DocumentMetadataBlock modal={modal} document={document} />
<div className="line-clamp-3 pt-2 text-sm font-normal leading-snug text-gray-600">
{buildDocumentSummaryDisplay(
document.match_highlights,
document.blurb
)}
</div>
<div className="absolute top-2 right-2">
{!isInternet && (
<DocumentSelector
isSelected={isSelected}
handleSelect={() => handleSelect(document.document_id)}
isDisabled={tokenLimitReached && !isSelected}
/>
)}
</div>
</a>
</div>
</div>
);

View File

@ -0,0 +1,186 @@
import { DanswerDocument } from "@/lib/search/interfaces";
import { ChatDocumentDisplay } from "./ChatDocumentDisplay";
import { usePopup } from "@/components/admin/connectors/Popup";
import { removeDuplicateDocs } from "@/lib/documentUtils";
import { Message } from "../interfaces";
import { ForwardedRef, forwardRef, useEffect, useState } from "react";
import { FilterManager } from "@/lib/hooks";
import { CCPairBasicInfo, DocumentSet, Tag } from "@/lib/types";
import { SourceSelector } from "../shared_chat_search/SearchFilters";
import { XIcon } from "@/components/icons/icons";
interface ChatFiltersProps {
filterManager: FilterManager;
closeSidebar: () => void;
selectedMessage: Message | null;
selectedDocuments: DanswerDocument[] | null;
toggleDocumentSelection: (document: DanswerDocument) => void;
clearSelectedDocuments: () => void;
selectedDocumentTokens: number;
maxTokens: number;
initialWidth: number;
isOpen: boolean;
modal: boolean;
ccPairs: CCPairBasicInfo[];
tags: Tag[];
documentSets: DocumentSet[];
showFilters: boolean;
}
export const ChatFilters = forwardRef<HTMLDivElement, ChatFiltersProps>(
(
{
closeSidebar,
modal,
selectedMessage,
selectedDocuments,
filterManager,
toggleDocumentSelection,
clearSelectedDocuments,
selectedDocumentTokens,
maxTokens,
initialWidth,
isOpen,
ccPairs,
tags,
documentSets,
showFilters,
},
ref: ForwardedRef<HTMLDivElement>
) => {
const { popup, setPopup } = usePopup();
const [delayedSelectedDocumentCount, setDelayedSelectedDocumentCount] =
useState(0);
useEffect(() => {
const timer = setTimeout(
() => {
setDelayedSelectedDocumentCount(selectedDocuments?.length || 0);
},
selectedDocuments?.length == 0 ? 1000 : 0
);
return () => clearTimeout(timer);
}, [selectedDocuments]);
const selectedDocumentIds =
selectedDocuments?.map((document) => document.document_id) || [];
const currentDocuments = selectedMessage?.documents || null;
const dedupedDocuments = removeDuplicateDocs(currentDocuments || []);
const tokenLimitReached = selectedDocumentTokens > maxTokens - 75;
const hasSelectedDocuments = selectedDocumentIds.length > 0;
return (
<div
id="danswer-chat-sidebar"
className={`relative py-2 max-w-full ${
!modal ? "border-l h-full border-sidebar-border" : ""
}`}
onClick={(e) => {
if (e.target === e.currentTarget) {
closeSidebar();
}
}}
>
<div
className={`ml-auto h-full relative sidebar transition-all duration-300
${
isOpen
? "opacity-100 translate-x-0"
: "opacity-0 translate-x-[10%]"
}`}
style={{
width: modal ? undefined : initialWidth,
}}
>
<div className="flex flex-col h-full">
{popup}
<div className="p-4 flex justify-between items-center">
<h2 className="text-xl font-bold text-text-900">
{showFilters ? "Filters" : "Sources"}
</h2>
<button
onClick={closeSidebar}
className="text-sm text-primary-600 mr-2 hover:text-primary-800 transition-colors duration-200 ease-in-out"
>
<XIcon className="w-4 h-4" />
</button>
</div>
<div className="border-b border-divider-history-sidebar-bar mx-3" />
<div className="overflow-y-auto -mx-1 sm:mx-0 flex-grow gap-y-0 default-scrollbar dark-scrollbar flex flex-col">
{showFilters ? (
<SourceSelector
modal={modal}
tagsOnLeft={true}
filtersUntoggled={false}
{...filterManager}
availableDocumentSets={documentSets}
existingSources={ccPairs.map((ccPair) => ccPair.source)}
availableTags={tags}
/>
) : (
<>
{dedupedDocuments.length > 0 ? (
dedupedDocuments.map((document, ind) => (
<div
key={document.document_id}
className={`${
ind === dedupedDocuments.length - 1
? ""
: "border-b border-border-light w-full"
}`}
>
<ChatDocumentDisplay
modal={modal}
document={document}
isSelected={selectedDocumentIds.includes(
document.document_id
)}
handleSelect={(documentId) => {
toggleDocumentSelection(
dedupedDocuments.find(
(doc) => doc.document_id === documentId
)!
);
}}
tokenLimitReached={tokenLimitReached}
/>
</div>
))
) : (
<div className="mx-3" />
)}
</>
)}
</div>
</div>
{!showFilters && (
<div
className={`sticky bottom-4 w-full left-0 flex justify-center transition-opacity duration-300 ${
hasSelectedDocuments
? "opacity-100"
: "opacity-0 pointer-events-none"
}`}
>
<button
className="text-sm font-medium py-2 px-4 rounded-full transition-colors bg-gray-900 text-white"
onClick={clearSelectedDocuments}
>
{`Remove ${
delayedSelectedDocumentCount > 0
? delayedSelectedDocumentCount
: ""
} Source${delayedSelectedDocumentCount > 1 ? "s" : ""}`}
</button>
</div>
)}
</div>
</div>
);
}
);
ChatFilters.displayName = "ChatFilters";

View File

@ -1,168 +0,0 @@
import { DanswerDocument } from "@/lib/search/interfaces";
import Text from "@/components/ui/text";
import { ChatDocumentDisplay } from "./ChatDocumentDisplay";
import { usePopup } from "@/components/admin/connectors/Popup";
import { removeDuplicateDocs } from "@/lib/documentUtils";
import { Message } from "../interfaces";
import { ForwardedRef, forwardRef } from "react";
import { Separator } from "@/components/ui/separator";
interface DocumentSidebarProps {
closeSidebar: () => void;
selectedMessage: Message | null;
selectedDocuments: DanswerDocument[] | null;
toggleDocumentSelection: (document: DanswerDocument) => void;
clearSelectedDocuments: () => void;
selectedDocumentTokens: number;
maxTokens: number;
isLoading: boolean;
initialWidth: number;
isOpen: boolean;
}
export const DocumentSidebar = forwardRef<HTMLDivElement, DocumentSidebarProps>(
(
{
closeSidebar,
selectedMessage,
selectedDocuments,
toggleDocumentSelection,
clearSelectedDocuments,
selectedDocumentTokens,
maxTokens,
isLoading,
initialWidth,
isOpen,
},
ref: ForwardedRef<HTMLDivElement>
) => {
const { popup, setPopup } = usePopup();
const selectedDocumentIds =
selectedDocuments?.map((document) => document.document_id) || [];
const currentDocuments = selectedMessage?.documents || null;
const dedupedDocuments = removeDuplicateDocs(currentDocuments || []);
// NOTE: do not allow selection if less than 75 tokens are left
// this is to prevent the case where they are able to select the doc
// but it basically is unused since it's truncated right at the very
// start of the document (since title + metadata + misc overhead) takes up
// space
const tokenLimitReached = selectedDocumentTokens > maxTokens - 75;
return (
<div
id="danswer-chat-sidebar"
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={`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,
}}
>
<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} Document
{dedupedDocuments.length > 1 ? "s" : ""}
<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>
<Separator className="mb-0 mt-4 pb-2" />
{currentDocuments ? (
<div className="overflow-y-auto flex-grow dark-scrollbar flex relative flex-col">
{dedupedDocuments.length > 0 ? (
dedupedDocuments.map((document, ind) => (
<div
key={document.document_id}
className={`${
ind === dedupedDocuments.length - 1
? "mb-5"
: "border-b border-border-light mb-3"
}`}
>
<ChatDocumentDisplay
document={document}
setPopup={setPopup}
queryEventId={null}
isAIPick={false}
isSelected={selectedDocumentIds.includes(
document.document_id
)}
handleSelect={(documentId) => {
toggleDocumentSelection(
dedupedDocuments.find(
(document) => document.document_id === documentId
)!
);
}}
tokenLimitReached={tokenLimitReached}
/>
</div>
))
) : (
<div className="mx-3">
<Text>No documents found for the query.</Text>
</div>
)}
</div>
) : (
!isLoading && (
<div className="ml-4 mr-3">
<Text>
When you run ask a question, the retrieved documents will
show up here!
</Text>
</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>
<button
className="bg-error text-xs p-2 rounded text-text-200"
onClick={() => {
clearSelectedDocuments();
closeSidebar();
}}
>
Delete Context
</button>
</div>
</div>
</div>
);
}
);
DocumentSidebar.displayName = "DocumentSidebar";

View File

@ -1,13 +1,9 @@
import React, { useContext, useEffect, useRef, useState } from "react";
import { FiPlusCircle, FiPlus, FiInfo, FiX } from "react-icons/fi";
import { FiPlusCircle, FiPlus, FiInfo, FiX, FiSearch } from "react-icons/fi";
import { ChatInputOption } from "./ChatInputOption";
import { Persona } from "@/app/admin/assistants/interfaces";
import { InputPrompt } from "@/app/admin/prompt-library/interfaces";
import {
FilterManager,
getDisplayNameForModel,
LlmOverrideManager,
} from "@/lib/hooks";
import { FilterManager, LlmOverrideManager } from "@/lib/hooks";
import { SelectedFilterDisplay } from "./SelectedFilterDisplay";
import { useChatContext } from "@/components/context/ChatContext";
import { getFinalLLM } from "@/lib/llm/utils";
@ -18,15 +14,10 @@ import {
} from "../files/InputBarPreview";
import {
AssistantsIconSkeleton,
CpuIconSkeleton,
FileIcon,
SendIcon,
StopGeneratingIcon,
} 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 { DanswerDocument } from "@/lib/search/interfaces";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import {
@ -40,10 +31,18 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
import { ChatState } from "../types";
import UnconfiguredProviderText from "@/components/chat_search/UnconfiguredProviderText";
import { useAssistants } from "@/components/context/AssistantsContext";
import AnimatedToggle from "@/components/search/SearchBar";
import { Popup } from "@/components/admin/connectors/Popup";
import { AssistantsTab } from "../modal/configuration/AssistantsTab";
import { IconType } from "react-icons";
import { LlmTab } from "../modal/configuration/LlmTab";
import { XIcon } from "lucide-react";
const MAX_INPUT_HEIGHT = 200;
export function ChatInputBar({
removeFilters,
removeDocs,
openModelSettings,
showDocs,
showConfigureAPIKey,
@ -68,7 +67,10 @@ export function ChatInputBar({
alternativeAssistant,
chatSessionId,
inputPrompts,
toggleFilters,
}: {
removeFilters: () => void;
removeDocs: () => void;
showConfigureAPIKey: () => void;
openModelSettings: () => void;
chatState: ChatState;
@ -90,6 +92,7 @@ export function ChatInputBar({
handleFileUpload: (files: File[]) => void;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
chatSessionId?: string;
toggleFilters?: () => void;
}) {
useEffect(() => {
const textarea = textAreaRef.current;
@ -370,9 +373,9 @@ export function ChatInputBar({
</div>
)}
<div>
{/* <div>
<SelectedFilterDisplay filterManager={filterManager} />
</div>
</div> */}
<UnconfiguredProviderText showConfigureAPIKey={showConfigureAPIKey} />
@ -429,16 +432,21 @@ export function ChatInputBar({
)}
{(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">
<div className="flex gap-x-1 px-2 overflow-visible 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]"
className="flex-none relative overflow-visible flex items-center gap-x-2 h-10 px-3 rounded-lg bg-background-150 hover:bg-background-200 transition-colors duration-300 cursor-pointer max-w-[150px]"
>
<FileIcon size={24} />
<p className="text-xs">
<FileIcon size={20} />
<span className="text-sm whitespace-nowrap overflow-hidden text-ellipsis">
{selectedDocuments.length} selected
</p>
</span>
<XIcon
onClick={removeDocs}
size={16}
className="text-text-400 hover:text-text-600 ml-auto"
/>
</button>
)}
{files.map((file) => (
@ -529,72 +537,6 @@ export function ChatInputBar({
suppressContentEditableWarning={true}
/>
<div className="flex items-center space-x-3 mr-12 px-4 pb-2">
<Popup
removePadding
content={(close) => (
<AssistantsTab
llmProviders={llmProviders}
selectedAssistant={selectedAssistant}
onSelect={(assistant) => {
setSelectedAssistant(assistant);
close();
}}
/>
)}
flexPriority="shrink"
position="top"
mobilePosition="top-right"
>
<ChatInputOption
toggle
flexPriority="shrink"
name={
selectedAssistant ? selectedAssistant.name : "Assistants"
}
Icon={AssistantsIconSkeleton as IconType}
/>
</Popup>
<Popup
tab
content={(close, ref) => (
<LlmTab
currentAssistant={alternativeAssistant || selectedAssistant}
openModelSettings={openModelSettings}
currentLlm={
llmOverrideManager.llmOverride.modelName ||
(selectedAssistant
? selectedAssistant.llm_model_version_override ||
llmOverrideManager.globalDefault.modelName ||
llmName
: llmName)
}
close={close}
ref={ref}
llmOverrideManager={llmOverrideManager}
chatSessionId={chatSessionId}
/>
)}
position="top"
>
<ChatInputOption
flexPriority="second"
toggle
name={
settings?.isMobile
? undefined
: getDisplayNameForModel(
llmOverrideManager.llmOverride.modelName ||
(selectedAssistant
? selectedAssistant.llm_model_version_override ||
llmOverrideManager.globalDefault.modelName ||
llmName
: llmName)
)
}
Icon={CpuIconSkeleton}
/>
</Popup>
<ChatInputOption
flexPriority="stiff"
name="File"
@ -614,6 +556,14 @@ export function ChatInputBar({
input.click();
}}
/>
{toggleFilters && (
<ChatInputOption
flexPriority="stiff"
name="Filters"
Icon={FiSearch}
onClick={toggleFilters}
/>
)}
</div>
<div className="absolute bottom-2.5 mobile:right-4 desktop:right-10">

View File

@ -2,6 +2,7 @@ import {
AnswerPiecePacket,
DanswerDocument,
Filters,
FinalContextDocs,
StreamStopInfo,
} from "@/lib/search/interfaces";
import { handleSSEStream } from "@/lib/search/streamingUtils";
@ -102,6 +103,7 @@ export type PacketType =
| ToolCallMetadata
| BackendMessage
| AnswerPiecePacket
| FinalContextDocs
| DocumentsResponse
| FileChatDisplay
| StreamingError
@ -147,7 +149,6 @@ export async function* sendMessage({
}): AsyncGenerator<PacketType, void, unknown> {
const documentsAreSelected =
selectedDocumentIds && selectedDocumentIds.length > 0;
const body = JSON.stringify({
alternate_assistant_id: alternateAssistantId,
chat_session_id: chatSessionId,
@ -639,6 +640,7 @@ export async function useScrollonStream({
endDivRef,
debounceNumber,
mobile,
enableAutoScroll,
}: {
chatState: ChatState;
scrollableDivRef: RefObject<HTMLDivElement>;
@ -647,6 +649,7 @@ export async function useScrollonStream({
endDivRef: RefObject<HTMLDivElement>;
debounceNumber: number;
mobile?: boolean;
enableAutoScroll?: boolean;
}) {
const mobileDistance = 900; // distance that should "engage" the scroll
const desktopDistance = 500; // distance that should "engage" the scroll
@ -659,6 +662,10 @@ export async function useScrollonStream({
const previousScroll = useRef<number>(0);
useEffect(() => {
if (!enableAutoScroll) {
return;
}
if (chatState != "input" && scrollableDivRef && scrollableDivRef.current) {
const newHeight: number = scrollableDivRef.current?.scrollTop!;
const heightDifference = newHeight - previousScroll.current;
@ -716,7 +723,7 @@ export async function useScrollonStream({
// scroll on end of stream if within distance
useEffect(() => {
if (scrollableDivRef?.current && chatState == "input") {
if (scrollableDivRef?.current && chatState == "input" && enableAutoScroll) {
if (scrollDist.current < distance - 50) {
scrollableDivRef?.current?.scrollBy({
left: 0,

View File

@ -1,8 +1,50 @@
import { Citation } from "@/components/search/results/Citation";
import { WebResultIcon } from "@/components/WebResultIcon";
import { LoadedDanswerDocument } from "@/lib/search/interfaces";
import { getSourceMetadata } from "@/lib/sources";
import { ValidSources } from "@/lib/types";
import React, { memo } from "react";
import isEqual from "lodash/isEqual";
export const MemoizedAnchor = memo(({ docs, children }: any) => {
console.log(children);
const value = children?.toString();
if (value?.startsWith("[") && value?.endsWith("]")) {
const match = value.match(/\[(\d+)\]/);
if (match) {
const index = parseInt(match[1], 10) - 1;
const associatedDoc = docs && docs[index];
const url = associatedDoc?.link
? new URL(associatedDoc.link).origin + "/favicon.ico"
: "";
const getIcon = (sourceType: ValidSources, link: string) => {
return getSourceMetadata(sourceType).icon({ size: 18 });
};
const icon =
associatedDoc?.source_type === "web" ? (
<WebResultIcon url={associatedDoc.link} />
) : (
getIcon(
associatedDoc?.source_type || "web",
associatedDoc?.link || ""
)
);
return (
<MemoizedLink document={{ ...associatedDoc, icon, url }}>
{children}
</MemoizedLink>
);
}
}
return <MemoizedLink>{children}</MemoizedLink>;
});
export const MemoizedLink = memo((props: any) => {
const { node, ...rest } = props;
const { node, document, ...rest } = props;
const value = rest.children;
if (value?.toString().startsWith("*")) {
@ -10,7 +52,16 @@ export const MemoizedLink = memo((props: any) => {
<div className="flex-none bg-background-800 inline-block rounded-full h-3 w-3 ml-2" />
);
} else if (value?.toString().startsWith("[")) {
return <Citation link={rest?.href}>{rest.children}</Citation>;
return (
<Citation
url={document?.url}
icon={document?.icon as React.ReactNode}
link={rest?.href}
document={document as LoadedDanswerDocument}
>
{rest.children}
</Citation>
);
} else {
return (
<a
@ -25,9 +76,16 @@ export const MemoizedLink = memo((props: any) => {
}
});
export const MemoizedParagraph = memo(({ ...props }: any) => {
return <p {...props} className="text-default" />;
});
export const MemoizedParagraph = memo(
function MemoizedParagraph({ children }: any) {
return <p className="text-default">{children}</p>;
},
(prevProps, nextProps) => {
const areEqual = isEqual(prevProps.children, nextProps.children);
return areEqual;
}
);
MemoizedAnchor.displayName = "MemoizedAnchor";
MemoizedLink.displayName = "MemoizedLink";
MemoizedParagraph.displayName = "MemoizedParagraph";

View File

@ -8,14 +8,22 @@ import {
FiGlobe,
} from "react-icons/fi";
import { FeedbackType } from "../types";
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import React, {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import ReactMarkdown from "react-markdown";
import {
DanswerDocument,
FilteredDanswerDocument,
} from "@/lib/search/interfaces";
import { SearchSummary } from "./SearchSummary";
import { SourceIcon } from "@/components/SourceIcon";
import { SkippedSearch } from "./SkippedSearch";
import remarkGfm from "remark-gfm";
import { CopyButton } from "@/components/CopyButton";
@ -36,8 +44,6 @@ import "prismjs/themes/prism-tomorrow.css";
import "./custom-code-styles.css";
import { Persona } from "@/app/admin/assistants/interfaces";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { Citation } from "@/components/search/results/Citation";
import { DocumentMetadataBlock } from "@/components/search/DocumentDisplay";
import { LikeFeedback, DislikeFeedback } from "@/components/icons/icons";
import {
@ -52,16 +58,18 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useMouseTracking } from "./hooks";
import { InternetSearchIcon } from "@/components/InternetSearchIcon";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import GeneratingImageDisplay from "../tools/GeneratingImageDisplay";
import RegenerateOption from "../RegenerateOption";
import { LlmOverride } from "@/lib/hooks";
import { ContinueGenerating } from "./ContinueMessage";
import { MemoizedLink, MemoizedParagraph } from "./MemoizedTextComponents";
import { MemoizedAnchor, MemoizedParagraph } from "./MemoizedTextComponents";
import { extractCodeText } from "./codeUtils";
import ToolResult from "../../../components/tools/ToolResult";
import CsvContent from "../../../components/tools/CSVContent";
import SourceCard, {
SeeMoreBlock,
} from "@/components/chat_search/sources/SourceCard";
const TOOLS_WITH_CUSTOM_HANDLING = [
SEARCH_TOOL_NAME,
@ -155,6 +163,7 @@ function FileDisplay({
export const AIMessage = ({
regenerate,
overriddenModel,
selectedMessageForDocDisplay,
continueGenerating,
shared,
isActive,
@ -162,6 +171,7 @@ export const AIMessage = ({
alternativeAssistant,
docs,
messageId,
documentSelectionToggled,
content,
files,
selectedDocuments,
@ -178,7 +188,10 @@ export const AIMessage = ({
currentPersona,
otherMessagesCanSwitchTo,
onMessageSelection,
index,
}: {
index?: number;
selectedMessageForDocDisplay?: number | null;
shared?: boolean;
isActive?: boolean;
continueGenerating?: () => void;
@ -191,6 +204,7 @@ export const AIMessage = ({
currentPersona: Persona;
messageId: number | null;
content: string | JSX.Element;
documentSelectionToggled?: boolean;
files?: FileDescriptor[];
query?: string;
citedDocuments?: [string, DanswerDocument][] | null;
@ -287,18 +301,31 @@ export const AIMessage = ({
});
}
const paragraphCallback = useCallback(
(props: any) => <MemoizedParagraph>{props.children}</MemoizedParagraph>,
[]
);
const anchorCallback = useCallback(
(props: any) => (
<MemoizedAnchor docs={docs}>{props.children}</MemoizedAnchor>
),
[docs]
);
const currentMessageInd = messageId
? otherMessagesCanSwitchTo?.indexOf(messageId)
: undefined;
const uniqueSources: ValidSources[] = Array.from(
new Set((docs || []).map((doc) => doc.source_type))
).slice(0, 3);
const markdownComponents = useMemo(
() => ({
a: MemoizedLink,
p: MemoizedParagraph,
code: ({ node, className, children, ...props }: any) => {
a: anchorCallback,
p: paragraphCallback,
code: ({ node, className, children }: any) => {
const codeText = extractCodeText(
node,
finalContent as string,
@ -312,7 +339,7 @@ export const AIMessage = ({
);
},
}),
[finalContent]
[anchorCallback, paragraphCallback, finalContent]
);
const renderedMarkdown = useMemo(() => {
@ -333,12 +360,11 @@ export const AIMessage = ({
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1;
return (
<div
id="danswer-ai-message"
ref={trackedElementRef}
className={"py-5 ml-4 px-5 relative flex "}
className={`py-5 ml-4 px-5 relative flex `}
>
<div
className={`mx-auto ${
@ -363,6 +389,7 @@ export const AIMessage = ({
!retrievalDisabled && (
<div className="mb-1">
<SearchSummary
index={index || 0}
query={query}
finished={toolCall?.tool_result != undefined}
hasDocs={hasDocs || false}
@ -423,6 +450,31 @@ export const AIMessage = ({
/>
)}
{docs && docs.length > 0 && (
<div className="mt-2 -mx-8 w-full mb-4 flex relative">
<div className="w-full">
<div className="px-8 flex gap-x-2">
{!settings?.isMobile &&
docs.length > 0 &&
docs
.slice(0, 2)
.map((doc, ind) => (
<SourceCard doc={doc} key={ind} />
))}
<SeeMoreBlock
documentSelectionToggled={
(documentSelectionToggled &&
selectedMessageForDocDisplay === messageId) ||
false
}
toggleDocumentSelection={toggleDocumentSelection}
uniqueSources={uniqueSources}
/>
</div>
</div>
</div>
)}
{content || files ? (
<>
<FileDisplay files={files || []} />
@ -438,81 +490,6 @@ export const AIMessage = ({
) : isComplete ? null : (
<></>
)}
{isComplete && docs && docs.length > 0 && (
<div className="mt-2 -mx-8 w-full mb-4 flex relative">
<div className="w-full">
<div className="px-8 flex gap-x-2">
{!settings?.isMobile &&
filteredDocs.length > 0 &&
filteredDocs.slice(0, 2).map((doc, ind) => (
<div
key={doc.document_id}
className={`w-[200px] rounded-lg flex-none transition-all duration-500 hover:bg-background-125 bg-text-100 px-4 pb-2 pt-1 border-b
`}
>
<a
href={doc.link || undefined}
target="_blank"
className="text-sm flex w-full pt-1 gap-x-1.5 overflow-hidden justify-between font-semibold text-text-700"
rel="noreferrer"
>
<Citation link={doc.link} index={ind + 1} />
<p className="shrink truncate ellipsis break-all">
{doc.semantic_identifier ||
doc.document_id}
</p>
<div className="ml-auto flex-none">
{doc.is_internet ? (
<InternetSearchIcon url={doc.link} />
) : (
<SourceIcon
sourceType={doc.source_type}
iconSize={18}
/>
)}
</div>
</a>
<div className="flex overscroll-x-scroll mt-.5">
<DocumentMetadataBlock document={doc} />
</div>
<div className="line-clamp-3 text-xs break-words pt-1">
{doc.blurb}
</div>
</div>
))}
<div
onClick={() => {
if (messageId) {
onMessageSelection?.(messageId);
}
toggleDocumentSelection?.();
}}
key={-1}
className="cursor-pointer w-[200px] rounded-lg flex-none transition-all duration-500 hover:bg-background-125 bg-text-100 px-4 py-2 border-b"
>
<div className="text-sm flex justify-between font-semibold text-text-700">
<p className="line-clamp-1">See context</p>
<div className="flex gap-x-1">
{uniqueSources.map((sourceType, ind) => {
return (
<div key={ind} className="flex-none">
<SourceIcon
sourceType={sourceType}
iconSize={18}
/>
</div>
);
})}
</div>
</div>
<div className="line-clamp-3 text-xs break-words pt-1">
See more
</div>
</div>
</div>
</div>
</div>
)}
</div>
{handleFeedback &&

View File

@ -41,6 +41,7 @@ export function ShowHideDocsButton({
}
export function SearchSummary({
index,
query,
hasDocs,
finished,
@ -48,6 +49,7 @@ export function SearchSummary({
handleShowRetrieved,
handleSearchQueryEdit,
}: {
index: number;
finished: boolean;
query: string;
hasDocs: boolean;
@ -98,7 +100,14 @@ export function SearchSummary({
!text-sm !line-clamp-1 !break-all px-0.5`}
ref={searchingForRef}
>
{finished ? "Searched" : "Searching"} for: <i> {finalQuery}</i>
{finished ? "Searched" : "Searching"} for:{" "}
<i>
{index === 1
? finalQuery.length > 50
? `${finalQuery.slice(0, 50)}...`
: finalQuery
: finalQuery}
</i>
</div>
</div>
);

View File

@ -53,7 +53,7 @@ export const FeedbackModal = ({
: predefinedNegativeFeedbackOptions;
return (
<Modal onOutsideClick={onClose} width="max-w-3xl">
<Modal onOutsideClick={onClose} width="w-full max-w-3xl">
<>
<h2 className="text-2xl text-emphasis font-bold mb-4 flex">
<div className="mr-1 my-auto">

View File

@ -1,4 +1,4 @@
import { Dispatch, SetStateAction, useEffect, useRef } from "react";
import { Dispatch, SetStateAction, useContext, useEffect, useRef } from "react";
import { Modal } from "@/components/Modal";
import Text from "@/components/ui/text";
import { getDisplayNameForModel, LlmOverride } from "@/lib/hooks";
@ -9,6 +9,10 @@ import { setUserDefaultModel } from "@/lib/users/UserSettings";
import { useRouter } from "next/navigation";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { useUser } from "@/components/user/UserProvider";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/admin/connectors/Field";
import { SettingsContext } from "@/components/settings/SettingsProvider";
export function SetDefaultModelModal({
setPopup,
@ -23,7 +27,7 @@ export function SetDefaultModelModal({
onClose: () => void;
defaultModel: string | null;
}) {
const { refreshUser } = useUser();
const { refreshUser, user, updateUserAutoScroll } = useUser();
const containerRef = useRef<HTMLDivElement>(null);
const messageRef = useRef<HTMLDivElement>(null);
@ -121,16 +125,41 @@ export function SetDefaultModelModal({
const defaultProvider = llmProviders.find(
(llmProvider) => llmProvider.is_default_provider
);
const settings = useContext(SettingsContext);
const autoScroll = settings?.enterpriseSettings?.auto_scroll;
const checked =
user?.preferences?.auto_scroll === null
? autoScroll
: user?.preferences?.auto_scroll;
return (
<Modal onOutsideClick={onClose} width="rounded-lg bg-white max-w-xl">
<>
<div className="flex mb-4">
<h2 className="text-2xl text-emphasis font-bold flex my-auto">
Set Default Model
User settings
</h2>
</div>
<div className="flex flex-col gap-y-2">
<div className="flex items-center gap-x-2">
<Switch
checked={checked}
onCheckedChange={(checked) => {
updateUserAutoScroll(checked);
}}
/>
<Label className="text-sm">Enable auto-scroll</Label>
</div>
</div>
<Separator />
<h3 className="text-lg text-emphasis font-bold">
Default model for assistants
</h3>
<Text className="mb-4">
Choose a Large Language Model (LLM) to serve as the default for
assistants that don&apos;t have a default model assigned.

View File

@ -32,6 +32,7 @@ export default async function Page(props: {
defaultAssistantId,
shouldShowWelcomeModal,
userInputPrompts,
ccPairs,
} = data;
return (
@ -44,6 +45,9 @@ export default async function Page(props: {
value={{
chatSessions,
availableSources,
ccPairs,
documentSets,
tags,
availableDocumentSets: documentSets,
availableTags: tags,
llmProviders,

View File

@ -113,7 +113,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
{page == "chat" && (
<div className="mx-3 mt-4 gap-y-1 flex-col text-text-history-sidebar-button flex gap-x-1.5 items-center items-center">
<Link
className="w-full p-2 bg-white border-border border rounded items-center hover:bg-background-200 cursor-pointer transition-all duration-150 flex gap-x-2"
className=" w-full p-2 bg-white border-border border rounded items-center hover:bg-background-200 cursor-pointer transition-all duration-150 flex gap-x-2"
href={
`/${page}` +
(NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA &&

View File

@ -0,0 +1,635 @@
import React, { useState } from "react";
import { DocumentSet, Tag, ValidSources } from "@/lib/types";
import { SourceMetadata } from "@/lib/search/interfaces";
import {
GearIcon,
InfoIcon,
MinusIcon,
PlusCircleIcon,
PlusIcon,
defaultTailwindCSS,
} from "@/components/icons/icons";
import { HoverPopup } from "@/components/HoverPopup";
import {
FiBook,
FiBookmark,
FiFilter,
FiMap,
FiTag,
FiX,
} from "react-icons/fi";
import { DateRangeSelector } from "@/components/search/DateRangeSelector";
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
import { listSourceMetadata } from "@/lib/sources";
import { SourceIcon } from "@/components/SourceIcon";
import { TagFilter } from "@/components/search/filtering/TagFilter";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { PopoverContent } from "@radix-ui/react-popover";
import { CalendarIcon } from "lucide-react";
import { buildDateString, getTimeAgoString } from "@/lib/dateUtils";
import { Separator } from "@/components/ui/separator";
import { FilterDropdown } from "@/components/search/filtering/FilterDropdown";
const SectionTitle = ({ children }: { children: string }) => (
<div className="font-bold text-xs mt-2 flex">{children}</div>
);
export interface SourceSelectorProps {
timeRange: DateRangePickerValue | null;
setTimeRange: React.Dispatch<
React.SetStateAction<DateRangePickerValue | null>
>;
showDocSidebar?: boolean;
selectedSources: SourceMetadata[];
setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>;
selectedDocumentSets: string[];
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
selectedTags: Tag[];
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
availableDocumentSets: DocumentSet[];
existingSources: ValidSources[];
availableTags: Tag[];
toggleFilters: () => void;
filtersUntoggled: boolean;
tagsOnLeft: boolean;
}
export function SourceSelector({
timeRange,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
selectedTags,
setSelectedTags,
availableDocumentSets,
existingSources,
availableTags,
showDocSidebar,
toggleFilters,
filtersUntoggled,
tagsOnLeft,
}: SourceSelectorProps) {
const handleSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
if (
prev.map((source) => source.internalName).includes(source.internalName)
) {
return prev.filter((s) => s.internalName !== source.internalName);
} else {
return [...prev, source];
}
});
};
const handleDocumentSetSelect = (documentSetName: string) => {
setSelectedDocumentSets((prev: string[]) => {
if (prev.includes(documentSetName)) {
return prev.filter((s) => s !== documentSetName);
} else {
return [...prev, documentSetName];
}
});
};
let allSourcesSelected = selectedSources.length > 0;
const toggleAllSources = () => {
if (allSourcesSelected) {
setSelectedSources([]);
} else {
const allSources = listSourceMetadata().filter((source) =>
existingSources.includes(source.internalName)
);
setSelectedSources(allSources);
}
};
return (
<div
className={`hidden ${
showDocSidebar ? "4xl:block" : "!block"
} duration-1000 flex ease-out transition-all transform origin-top-right`}
>
<button onClick={() => toggleFilters()} className="flex text-emphasis">
<h2 className="font-bold my-auto">Filters</h2>
<FiFilter className="my-auto ml-2" size="16" />
</button>
{!filtersUntoggled && (
<>
<Separator />
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer">
<div className="flex justify-between items-center">
<SectionTitle>Time Range</SectionTitle>
{true && (
<button
onClick={(e) => {
e.stopPropagation();
setTimeRange(null);
}}
className="text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
)}
</div>
<p className="text-sm text-default mt-2">
{getTimeAgoString(timeRange?.from!) || "Select a time range"}
</p>
</div>
</PopoverTrigger>
<PopoverContent
className="bg-background-search-filter border-border border rounded-md z-[200] p-0"
align="start"
>
<Calendar
mode="range"
selected={
timeRange
? {
from: new Date(timeRange.from),
to: new Date(timeRange.to),
}
: undefined
}
onSelect={(daterange) => {
const initialDate = daterange?.from || new Date();
const endDate = daterange?.to || new Date();
setTimeRange({
from: initialDate,
to: endDate,
selectValue: timeRange?.selectValue || "",
});
}}
className="rounded-md "
/>
</PopoverContent>
</Popover>
{availableTags.length > 0 && (
<>
<div className="mt-4 mb-2">
<SectionTitle>Tags</SectionTitle>
</div>
<TagFilter
showTagsOnLeft={true}
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
</>
)}
{existingSources.length > 0 && (
<div className="mt-4">
<div className="flex w-full gap-x-2 items-center">
<div className="font-bold text-xs mt-2 flex items-center gap-x-2">
<p>Sources</p>
<input
type="checkbox"
checked={allSourcesSelected}
onChange={toggleAllSources}
className="my-auto form-checkbox h-3 w-3 text-primary border-background-900 rounded"
/>
</div>
</div>
<div className="px-1">
{listSourceMetadata()
.filter((source) =>
existingSources.includes(source.internalName)
)
.map((source) => (
<div
key={source.internalName}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 my-1.5 rounded-lg px-2 select-none " +
(selectedSources
.map((source) => source.internalName)
.includes(source.internalName)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleSelect(source)}
>
<SourceIcon
sourceType={source.internalName}
iconSize={16}
/>
<span className="ml-2 text-sm text-default">
{source.displayName}
</span>
</div>
))}
</div>
</div>
)}
{availableDocumentSets.length > 0 && (
<>
<div className="mt-4">
<SectionTitle>Knowledge Sets</SectionTitle>
</div>
<div className="px-1">
{availableDocumentSets.map((documentSet) => (
<div key={documentSet.name} className="my-1.5 flex">
<div
key={documentSet.name}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 rounded-lg px-2 " +
(selectedDocumentSets.includes(documentSet.name)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleDocumentSetSelect(documentSet.name)}
>
<HoverPopup
mainContent={
<div className="flex my-auto mr-2">
<InfoIcon className={defaultTailwindCSS} />
</div>
}
popupContent={
<div className="text-sm w-64">
<div className="flex font-medium">Description</div>
<div className="mt-1">
{documentSet.description}
</div>
</div>
}
classNameModifications="-ml-2"
/>
<span className="text-sm">{documentSet.name}</span>
</div>
</div>
))}
</div>
</>
)}
</>
)}
</div>
);
}
export function SelectedBubble({
children,
onClick,
}: {
children: string | JSX.Element;
onClick: () => void;
}) {
return (
<div
className={
"flex cursor-pointer items-center border border-border " +
"py-1 my-1.5 rounded-lg px-2 w-fit hover:bg-hover"
}
onClick={onClick}
>
{children}
<FiX className="ml-2" size={14} />
</div>
);
}
export function HorizontalFilters({
timeRange,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
availableDocumentSets,
existingSources,
}: SourceSelectorProps) {
const handleSourceSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
const prevSourceNames = prev.map((source) => source.internalName);
if (prevSourceNames.includes(source.internalName)) {
return prev.filter((s) => s.internalName !== source.internalName);
} else {
return [...prev, source];
}
});
};
const handleDocumentSetSelect = (documentSetName: string) => {
setSelectedDocumentSets((prev: string[]) => {
if (prev.includes(documentSetName)) {
return prev.filter((s) => s !== documentSetName);
} else {
return [...prev, documentSetName];
}
});
};
const allSources = listSourceMetadata();
const availableSources = allSources.filter((source) =>
existingSources.includes(source.internalName)
);
return (
<div>
<div className="flex gap-x-3">
<div className="w-64">
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
</div>
<FilterDropdown
options={availableSources.map((source) => {
return {
key: source.displayName,
display: (
<>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2 text-sm">{source.displayName}</span>
</>
),
};
})}
selected={selectedSources.map((source) => source.displayName)}
handleSelect={(option) =>
handleSourceSelect(
allSources.find((source) => source.displayName === option.key)!
)
}
icon={
<div className="my-auto mr-2 w-[16px] h-[16px]">
<FiMap size={16} />
</div>
}
defaultDisplay="All Sources"
/>
<FilterDropdown
options={availableDocumentSets.map((documentSet) => {
return {
key: documentSet.name,
display: (
<>
<div className="my-auto">
<FiBookmark />
</div>
<span className="ml-2 text-sm">{documentSet.name}</span>
</>
),
};
})}
selected={selectedDocumentSets}
handleSelect={(option) => handleDocumentSetSelect(option.key)}
icon={
<div className="my-auto mr-2 w-[16px] h-[16px]">
<FiBook size={16} />
</div>
}
defaultDisplay="All Document Sets"
/>
</div>
<div className="flex pb-4 mt-2 h-12">
<div className="flex flex-wrap gap-x-2">
{timeRange && timeRange.selectValue && (
<SelectedBubble onClick={() => setTimeRange(null)}>
<div className="text-sm flex">{timeRange.selectValue}</div>
</SelectedBubble>
)}
{existingSources.length > 0 &&
selectedSources.map((source) => (
<SelectedBubble
key={source.internalName}
onClick={() => handleSourceSelect(source)}
>
<>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2 text-sm">{source.displayName}</span>
</>
</SelectedBubble>
))}
{selectedDocumentSets.length > 0 &&
selectedDocumentSets.map((documentSetName) => (
<SelectedBubble
key={documentSetName}
onClick={() => handleDocumentSetSelect(documentSetName)}
>
<>
<div>
<FiBookmark />
</div>
<span className="ml-2 text-sm">{documentSetName}</span>
</>
</SelectedBubble>
))}
</div>
</div>
</div>
);
}
export function HorizontalSourceSelector({
timeRange,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
selectedTags,
setSelectedTags,
availableDocumentSets,
existingSources,
availableTags,
}: SourceSelectorProps) {
const handleSourceSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
if (prev.map((s) => s.internalName).includes(source.internalName)) {
return prev.filter((s) => s.internalName !== source.internalName);
} else {
return [...prev, source];
}
});
};
const handleDocumentSetSelect = (documentSetName: string) => {
setSelectedDocumentSets((prev: string[]) => {
if (prev.includes(documentSetName)) {
return prev.filter((s) => s !== documentSetName);
} else {
return [...prev, documentSetName];
}
});
};
const handleTagSelect = (tag: Tag) => {
setSelectedTags((prev: Tag[]) => {
if (
prev.some(
(t) => t.tag_key === tag.tag_key && t.tag_value === tag.tag_value
)
) {
return prev.filter(
(t) => !(t.tag_key === tag.tag_key && t.tag_value === tag.tag_value)
);
} else {
return [...prev, tag];
}
});
};
const resetSources = () => {
setSelectedSources([]);
};
const resetDocuments = () => {
setSelectedDocumentSets([]);
};
const resetTags = () => {
setSelectedTags([]);
};
return (
<div className="flex flex-nowrap space-x-2">
<Popover>
<PopoverTrigger asChild>
<div
className={`
border
max-w-36
border-border
rounded-lg
max-h-96
overflow-y-scroll
overscroll-contain
px-3
text-sm
py-1.5
select-none
cursor-pointer
w-fit
gap-x-1
hover:bg-hover
flex
items-center
bg-background-search-filter
`}
>
<CalendarIcon className="h-4 w-4" />
{timeRange?.from ? getTimeAgoString(timeRange.from) : "Since"}
</div>
</PopoverTrigger>
<PopoverContent
className="bg-background-search-filter border-border border rounded-md z-[200] p-0"
align="start"
>
<Calendar
mode="range"
selected={
timeRange
? { from: new Date(timeRange.from), to: new Date(timeRange.to) }
: undefined
}
onSelect={(daterange) => {
const initialDate = daterange?.from || new Date();
const endDate = daterange?.to || new Date();
setTimeRange({
from: initialDate,
to: endDate,
selectValue: timeRange?.selectValue || "",
});
}}
className="rounded-md"
/>
</PopoverContent>
</Popover>
{existingSources.length > 0 && (
<FilterDropdown
backgroundColor="bg-background-search-filter"
options={listSourceMetadata()
.filter((source) => existingSources.includes(source.internalName))
.map((source) => ({
key: source.internalName,
display: (
<>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2 text-sm">{source.displayName}</span>
</>
),
}))}
selected={selectedSources.map((source) => source.internalName)}
handleSelect={(option) =>
handleSourceSelect(
listSourceMetadata().find((s) => s.internalName === option.key)!
)
}
icon={<FiMap size={16} />}
defaultDisplay="Sources"
dropdownColor="bg-background-search-filter-dropdown"
width="w-fit ellipsis truncate"
resetValues={resetSources}
dropdownWidth="w-40"
optionClassName="truncate w-full break-all ellipsis"
/>
)}
{availableDocumentSets.length > 0 && (
<FilterDropdown
backgroundColor="bg-background-search-filter"
options={availableDocumentSets.map((documentSet) => ({
key: documentSet.name,
display: <>{documentSet.name}</>,
}))}
selected={selectedDocumentSets}
handleSelect={(option) => handleDocumentSetSelect(option.key)}
icon={<FiBook size={16} />}
defaultDisplay="Sets"
resetValues={resetDocuments}
width="w-fit max-w-24 text-ellipsis truncate"
dropdownColor="bg-background-search-filter-dropdown"
dropdownWidth="max-w-36 w-fit"
optionClassName="truncate w-full break-all"
/>
)}
{availableTags.length > 0 && (
<FilterDropdown
backgroundColor="bg-background-search-filter"
options={availableTags.map((tag) => ({
key: `${tag.tag_key}=${tag.tag_value}`,
display: (
<span className="text-sm">
{tag.tag_key}
<b>=</b>
{tag.tag_value}
</span>
),
}))}
selected={selectedTags.map(
(tag) => `${tag.tag_key}=${tag.tag_value}`
)}
handleSelect={(option) => {
const [tag_key, tag_value] = option.key.split("=");
const selectedTag = availableTags.find(
(tag) => tag.tag_key === tag_key && tag.tag_value === tag_value
);
if (selectedTag) {
handleTagSelect(selectedTag);
}
}}
icon={<FiTag size={16} />}
defaultDisplay="Tags"
resetValues={resetTags}
dropdownColor="bg-background-search-filter-dropdown"
width="w-fit max-w-24 ellipsis truncate"
dropdownWidth="max-w-80 w-fit"
optionClassName="truncate w-full break-all ellipsis"
/>
)}
</div>
);
}

View File

@ -21,9 +21,7 @@ export default function FixedLogo({
return (
<>
<Link
href={
settings && settings.default_page === "chat" ? "/chat" : "/search"
}
href="/chat"
className="fixed cursor-pointer flex z-40 left-2.5 top-2"
>
<div className="max-w-[200px] mobile:hidden flex items-center gap-x-1 my-auto">
@ -49,7 +47,7 @@ export default function FixedLogo({
</div>
</Link>
<div className="mobile:hidden fixed left-2.5 bottom-4">
<FiSidebar className="text-text-mobile-sidebar" />
{/* <FiSidebar className="text-text-mobile-sidebar" /> */}
</div>
</>
);

View File

@ -1,90 +1,7 @@
"use client";
import React, { ReactNode, useContext, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChatIcon, SearchIcon } from "@/components/icons/icons";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import KeyboardSymbol from "@/lib/browserUtilities";
const ToggleSwitch = () => {
const commandSymbol = KeyboardSymbol();
const pathname = usePathname();
const router = useRouter();
const settings = useContext(SettingsContext);
const [activeTab, setActiveTab] = useState(() => {
return pathname == "/search" ? "search" : "chat";
});
const [isInitialLoad, setIsInitialLoad] = useState(true);
useEffect(() => {
const newTab = pathname === "/search" ? "search" : "chat";
setActiveTab(newTab);
localStorage.setItem("activeTab", newTab);
setIsInitialLoad(false);
}, [pathname]);
const handleTabChange = (tab: string) => {
setActiveTab(tab);
localStorage.setItem("activeTab", tab);
if (settings?.isMobile && window) {
window.location.href = tab;
} else {
router.push(tab === "search" ? "/search" : "/chat");
}
};
return (
<div className="bg-background-toggle mobile:mt-8 flex rounded-full p-1">
<div
className={`absolute mobile:mt-8 top-1 bottom-1 ${
activeTab === "chat" ? "w-[45%]" : "w-[50%]"
} bg-white rounded-full shadow ${
isInitialLoad ? "" : "transition-transform duration-300 ease-in-out"
} ${activeTab === "chat" ? "translate-x-[115%]" : "translate-x-[1%]"}`}
/>
<button
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-300 ease-in-out flex items-center relative z-10 ${
activeTab === "search"
? "text-text-application-toggled"
: "text-text-application-untoggled hover:text-text-application-untoggled-hover"
}`}
onClick={() => handleTabChange("search")}
>
<SearchIcon size={16} className="mr-2" />
<div className="flex items-center">
Search
<div className="ml-2 flex content-center">
<span className="leading-none pb-[1px] my-auto">
{commandSymbol}
</span>
<span className="my-auto">S</span>
</div>
</div>
</button>
<button
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-300 ease-in-out flex items-center relative z-10 ${
activeTab === "chat"
? "text-text-application-toggled"
: "text-text-application-untoggled hover:text-text-application-untoggled-hover"
}`}
onClick={() => handleTabChange("chat")}
>
<ChatIcon size={16} className="mr-2" />
<div className="items-end flex">
Chat
<div className="ml-2 flex content-center">
<span className="leading-none pb-[1px] my-auto">
{commandSymbol}
</span>
<span className="my-auto">D</span>
</div>
</div>
</button>
</div>
);
};
import React, { ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
export default function FunctionalWrapper({
initiallyToggled,
@ -128,12 +45,6 @@ export default function FunctionalWrapper({
window.removeEventListener("keydown", handleKeyDown);
};
}, [router]);
const combinedSettings = useContext(SettingsContext);
const settings = combinedSettings?.settings;
const chatBannerPresent =
combinedSettings?.enterpriseSettings?.custom_header_content;
const twoLines =
combinedSettings?.enterpriseSettings?.two_lines_for_chat_header;
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
@ -145,24 +56,7 @@ export default function FunctionalWrapper({
return (
<>
{(!settings ||
(settings.search_page_enabled && settings.chat_page_enabled)) && (
<div
className={`mobile:hidden z-30 flex fixed ${
chatBannerPresent ? (twoLines ? "top-20" : "top-14") : "top-4"
} left-1/2 transform -translate-x-1/2`}
>
<div
style={{ transition: "width 0.30s ease-out" }}
className={`flex-none overflow-y-hidden bg-background-100 transition-all bg-opacity-80 duration-300 ease-in-out h-full
${toggledSidebar ? "w-[250px] " : "w-[0px]"}`}
/>
<div className="relative">
<ToggleSwitch />
</div>
</div>
)}
{" "}
<div className="overscroll-y-contain overflow-y-scroll overscroll-contain left-0 top-0 w-full h-svh">
{content(toggledSidebar, toggle)}
</div>

View File

@ -0,0 +1,294 @@
import { DocumentSet, Tag, ValidSources } from "@/lib/types";
import { SourceMetadata } from "@/lib/search/interfaces";
import { InfoIcon, defaultTailwindCSS } from "@/components/icons/icons";
import { HoverPopup } from "@/components/HoverPopup";
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
import { SourceIcon } from "@/components/SourceIcon";
import { Checkbox } from "@/components/ui/checkbox";
import { TagFilter } from "@/components/search/filtering/TagFilter";
import { CardContent } from "@/components/ui/card";
import { useEffect } from "react";
import { useState } from "react";
import { listSourceMetadata } from "@/lib/sources";
import { Calendar } from "@/components/ui/calendar";
import { getDateRangeString } from "@/lib/dateUtils";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ToolTipDetails } from "@/components/admin/connectors/Field";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipProvider } from "@radix-ui/react-tooltip";
const SectionTitle = ({
children,
modal,
}: {
children: string;
modal?: boolean;
}) => (
<div className={`mt-4 pb-2 ${modal ? "w-[80vw]" : "w-full"}`}>
<p className="text-sm font-semibold">{children}</p>
</div>
);
export interface SourceSelectorProps {
timeRange: DateRangePickerValue | null;
setTimeRange: React.Dispatch<
React.SetStateAction<DateRangePickerValue | null>
>;
showDocSidebar?: boolean;
selectedSources: SourceMetadata[];
setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>;
selectedDocumentSets: string[];
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
selectedTags: Tag[];
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
availableDocumentSets: DocumentSet[];
existingSources: ValidSources[];
availableTags: Tag[];
filtersUntoggled: boolean;
modal?: boolean;
tagsOnLeft: boolean;
}
export function SourceSelector({
timeRange,
filtersUntoggled,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
selectedTags,
setSelectedTags,
availableDocumentSets,
existingSources,
modal,
availableTags,
}: SourceSelectorProps) {
const handleSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
if (
prev.map((source) => source.internalName).includes(source.internalName)
) {
return prev.filter((s) => s.internalName !== source.internalName);
} else {
return [...prev, source];
}
});
};
const handleDocumentSetSelect = (documentSetName: string) => {
setSelectedDocumentSets((prev: string[]) => {
if (prev.includes(documentSetName)) {
return prev.filter((s) => s !== documentSetName);
} else {
return [...prev, documentSetName];
}
});
};
let allSourcesSelected = selectedSources.length > 0;
const toggleAllSources = () => {
if (allSourcesSelected) {
setSelectedSources([]);
} else {
const allSources = listSourceMetadata().filter((source) =>
existingSources.includes(source.internalName)
);
setSelectedSources(allSources);
}
};
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const calendar = document.querySelector(".rdp");
if (calendar && !calendar.contains(event.target as Node)) {
setIsCalendarOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<div>
{!filtersUntoggled && (
<CardContent className=" space-y-2">
<div>
<div className="flex py-2 mt-2 justify-start gap-x-2 items-center">
<p className="text-sm font-semibold">Time Range</p>
{timeRange && (
<button
onClick={(e) => {
e.stopPropagation();
setTimeRange(null);
}}
className="text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
)}
</div>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`w-full justify-start text-left font-normal`}
>
<span>
{getDateRangeString(timeRange?.from!, timeRange?.to!) ||
"Select a time range"}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="z-[10000] w-auto p-0" align="start">
<Calendar
mode="range"
selected={
timeRange
? {
from: new Date(timeRange.from),
to: new Date(timeRange.to),
}
: undefined
}
onSelect={(daterange) => {
const today = new Date();
const initialDate = daterange?.from
? new Date(
Math.min(daterange.from.getTime(), today.getTime())
)
: today;
const endDate = daterange?.to
? new Date(
Math.min(daterange.to.getTime(), today.getTime())
)
: today;
setTimeRange({
from: initialDate,
to: endDate,
selectValue: timeRange?.selectValue || "",
});
}}
className="rounded-md"
/>
</PopoverContent>
</Popover>
</div>
{availableTags.length > 0 && (
<div>
<SectionTitle modal={modal}>Tags</SectionTitle>
<TagFilter
modal={modal}
showTagsOnLeft={true}
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
</div>
)}
{existingSources.length > 0 && (
<div>
<SectionTitle modal={modal}>Sources</SectionTitle>
<div className="space-y-0">
{existingSources.length > 1 && (
<div className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2">
<Checkbox
id="select-all-sources"
checked={allSourcesSelected}
onCheckedChange={toggleAllSources}
/>
<label
htmlFor="select-all-sources"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Select All
</label>
</div>
)}
{listSourceMetadata()
.filter((source) =>
existingSources.includes(source.internalName)
)
.map((source) => (
<div
key={source.internalName}
className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2"
onClick={() => handleSelect(source)}
>
<Checkbox
checked={selectedSources
.map((s) => s.internalName)
.includes(source.internalName)}
/>
<SourceIcon
sourceType={source.internalName}
iconSize={16}
/>
<span className="text-sm">{source.displayName}</span>
</div>
))}
</div>
</div>
)}
{availableDocumentSets.length > 0 && (
<div>
<SectionTitle modal={modal}>Knowledge Sets</SectionTitle>
<div className="space-y-2">
{availableDocumentSets.map((documentSet) => (
<div
key={documentSet.name}
className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2"
onClick={() => handleDocumentSetSelect(documentSet.name)}
>
<Checkbox
checked={selectedDocumentSets.includes(documentSet.name)}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon
className={`${defaultTailwindCSS} h-4 w-4`}
/>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm w-64">
<div className="font-medium">Description</div>
<div className="mt-1">
{documentSet.description}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="text-sm">{documentSet.name}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
)}
</div>
);
}

View File

@ -55,6 +55,7 @@ export function WhitelabelingForm() {
<div>
<Formik
initialValues={{
auto_scroll: enterpriseSettings?.auto_scroll || false,
application_name: enterpriseSettings?.application_name || null,
use_custom_logo: enterpriseSettings?.use_custom_logo || false,
use_custom_logotype: enterpriseSettings?.use_custom_logotype || false,
@ -71,6 +72,7 @@ export function WhitelabelingForm() {
enterpriseSettings?.enable_consent_screen || false,
}}
validationSchema={Yup.object().shape({
auto_scroll: Yup.boolean().nullable(),
application_name: Yup.string().nullable(),
use_custom_logo: Yup.boolean().required(),
use_custom_logotype: Yup.boolean().required(),

View File

@ -1,15 +1,5 @@
import { fetchSettingsSS } from "@/components/settings/lib";
import { redirect } from "next/navigation";
export default async function Page() {
const settings = await fetchSettingsSS();
if (!settings) {
redirect("/search");
}
if (settings.settings.default_page === "search") {
redirect("/search");
} else {
redirect("/chat");
}
redirect("/chat");
}

View File

@ -1,24 +0,0 @@
"use client";
import { SearchSection } from "@/components/search/SearchSection";
import FunctionalWrapper from "../chat/shared_chat_search/FunctionalWrapper";
export default function WrappedSearch({
searchTypeDefault,
initiallyToggled,
}: {
searchTypeDefault: string;
initiallyToggled: boolean;
}) {
return (
<FunctionalWrapper
initiallyToggled={initiallyToggled}
content={(toggledSidebar, toggle) => (
<SearchSection
toggle={toggle}
toggledSidebar={toggledSidebar}
defaultSearchType={searchTypeDefault}
/>
)}
/>
);
}

View File

@ -1,213 +0,0 @@
import {
AuthTypeMetadata,
getAuthTypeMetadataSS,
getCurrentUserSS,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { fetchSS } from "@/lib/utilsSS";
import { CCPairBasicInfo, DocumentSet, Tag, User } from "@/lib/types";
import { cookies } from "next/headers";
import { SearchType } from "@/lib/search/interfaces";
import { Persona } from "../admin/assistants/interfaces";
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { personaComparator } from "../admin/assistants/lib";
import { FullEmbeddingModelResponse } from "@/components/embedding/interfaces";
import { ChatPopup } from "../chat/ChatPopup";
import {
FetchAssistantsResponse,
fetchAssistantsSS,
} from "@/lib/assistants/fetchAssistantsSS";
import { ChatSession } from "../chat/interfaces";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
import {
AGENTIC_SEARCH_TYPE_COOKIE_NAME,
NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN,
DISABLE_LLM_DOC_RELEVANCE,
} from "@/lib/constants";
import WrappedSearch from "./WrappedSearch";
import { SearchProvider } from "@/components/context/SearchContext";
import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs";
import { LLMProviderDescriptor } from "../admin/configuration/llm/interfaces";
import { headers } from "next/headers";
import {
hasCompletedWelcomeFlowSS,
WelcomeModal,
} from "@/components/initialSetup/welcome/WelcomeModalWrapper";
export default async function Home(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
// Disable caching so we always get the up to date connector / document set / persona info
// importantly, this prevents users from adding a connector, going back to the main page,
// and then getting hit with a "No Connectors" popup
noStore();
const requestCookies = await cookies();
const tasks = [
getAuthTypeMetadataSS(),
getCurrentUserSS(),
fetchSS("/manage/indexing-status"),
fetchSS("/manage/document-set"),
fetchAssistantsSS(),
fetchSS("/query/valid-tags"),
fetchSS("/query/user-searches"),
fetchLLMProvidersSS(),
];
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let results: (
| User
| Response
| AuthTypeMetadata
| FullEmbeddingModelResponse
| FetchAssistantsResponse
| LLMProviderDescriptor[]
| null
)[] = [null, null, null, null, null, null, null, null];
try {
results = await Promise.all(tasks);
} catch (e) {
console.log(`Some fetch failed for the main search page - ${e}`);
}
const authTypeMetadata = results[0] as AuthTypeMetadata | null;
const user = results[1] as User | null;
const ccPairsResponse = results[2] as Response | null;
const documentSetsResponse = results[3] as Response | null;
const [initialAssistantsList, assistantsFetchError] =
results[4] as FetchAssistantsResponse;
const tagsResponse = results[5] as Response | null;
const queryResponse = results[6] as Response | null;
const llmProviders = (results[7] || []) as LLMProviderDescriptor[];
const authDisabled = authTypeMetadata?.authType === "disabled";
if (!authDisabled && !user) {
const headersList = await headers();
const fullUrl = headersList.get("x-url") || "/search";
const searchParamsString = new URLSearchParams(
searchParams as unknown as Record<string, string>
).toString();
const redirectUrl = searchParamsString
? `${fullUrl}?${searchParamsString}`
: fullUrl;
return redirect(`/auth/login?next=${encodeURIComponent(redirectUrl)}`);
}
if (user && !user.is_verified && authTypeMetadata?.requiresVerification) {
return redirect("/auth/waiting-on-verification");
}
let ccPairs: CCPairBasicInfo[] = [];
if (ccPairsResponse?.ok) {
ccPairs = await ccPairsResponse.json();
} else {
console.log(`Failed to fetch connectors - ${ccPairsResponse?.status}`);
}
let documentSets: DocumentSet[] = [];
if (documentSetsResponse?.ok) {
documentSets = await documentSetsResponse.json();
} else {
console.log(
`Failed to fetch document sets - ${documentSetsResponse?.status}`
);
}
let querySessions: ChatSession[] = [];
if (queryResponse?.ok) {
querySessions = (await queryResponse.json()).sessions;
} else {
console.log(`Failed to fetch chat sessions - ${queryResponse?.text()}`);
}
let assistants: Persona[] = initialAssistantsList;
if (assistantsFetchError) {
console.log(`Failed to fetch assistants - ${assistantsFetchError}`);
} else {
// remove those marked as hidden by an admin
assistants = assistants.filter((assistant) => assistant.is_visible);
// hide personas with no retrieval
assistants = assistants.filter((assistant) => assistant.num_chunks !== 0);
// sort them in priority order
assistants.sort(personaComparator);
}
let tags: Tag[] = [];
if (tagsResponse?.ok) {
tags = (await tagsResponse.json()).tags;
} else {
console.log(`Failed to fetch tags - ${tagsResponse?.status}`);
}
// needs to be done in a non-client side component due to nextjs
const storedSearchType = requestCookies.get("searchType")?.value as
| string
| undefined;
const searchTypeDefault: SearchType =
storedSearchType !== undefined &&
SearchType.hasOwnProperty(storedSearchType)
? (storedSearchType as SearchType)
: SearchType.SEMANTIC; // default to semantic
const hasAnyConnectors = ccPairs.length > 0;
const shouldShowWelcomeModal =
!llmProviders.length &&
!hasCompletedWelcomeFlowSS(requestCookies) &&
!hasAnyConnectors &&
(!user || user.role === "admin");
const shouldDisplayNoSourcesModal =
(!user || user.role === "admin") &&
ccPairs.length === 0 &&
!shouldShowWelcomeModal;
const sidebarToggled = requestCookies.get(SIDEBAR_TOGGLED_COOKIE_NAME);
const agenticSearchToggle = requestCookies.get(
AGENTIC_SEARCH_TYPE_COOKIE_NAME
);
const toggleSidebar = sidebarToggled
? sidebarToggled.value.toLocaleLowerCase() == "true" || false
: NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN;
const agenticSearchEnabled = agenticSearchToggle
? agenticSearchToggle.value.toLocaleLowerCase() == "true" || false
: false;
return (
<>
<HealthCheckBanner />
<InstantSSRAutoRefresh />
{shouldShowWelcomeModal && (
<WelcomeModal user={user} requestCookies={requestCookies} />
)}
{/* ChatPopup is a custom popup that displays a admin-specified message on initial user visit.
Only used in the EE version of the app. */}
<ChatPopup />
<SearchProvider
value={{
querySessions,
ccPairs,
documentSets,
assistants,
tags,
agenticSearchEnabled,
disabledAgentic: DISABLE_LLM_DOC_RELEVANCE,
initiallyToggled: toggleSidebar,
shouldShowWelcomeModal,
shouldDisplayNoSources: shouldDisplayNoSourcesModal,
}}
>
<WrappedSearch
initiallyToggled={toggleSidebar}
searchTypeDefault={searchTypeDefault}
/>
</SearchProvider>
</>
);
}

View File

@ -1,9 +0,0 @@
export function InternetSearchIcon({ url }: { url: string }) {
return (
<img
className="rounded-full w-[18px] h-[18px]"
src={`https://www.google.com/s2/favicons?sz=128&domain=${url}`}
alt="favicon"
/>
);
}

View File

@ -1,9 +1,11 @@
export function MetadataBadge({
icon,
value,
flexNone,
}: {
icon?: React.FC<{ size?: number; className?: string }>;
value: string | JSX.Element;
flexNone?: boolean;
}) {
return (
<div
@ -18,9 +20,13 @@ export function MetadataBadge({
w-fit
my-auto
select-none
`}
${flexNone ? "flex-none" : ""}`}
>
{icon && icon({ size: 12, className: "mr-0.5 my-auto" })}
{icon &&
icon({
size: 12,
className: flexNone ? "flex-none" : "mr-0.5 my-auto",
})}
<div className="my-auto flex">{value}</div>
</div>
);

View File

@ -1,11 +1,11 @@
"use client";
import { Separator } from "@/components/ui/separator";
import { FiX } from "react-icons/fi";
import { IconProps, XIcon } from "./icons/icons";
import { useRef } from "react";
import { isEventWithinRef } from "@/lib/contains";
import ReactDOM from "react-dom";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface ModalProps {
icon?: ({ size, className }: IconProps) => JSX.Element;
@ -18,6 +18,8 @@ interface ModalProps {
hideDividerForTitle?: boolean;
hideCloseButton?: boolean;
noPadding?: boolean;
height?: string;
noScroll?: boolean;
}
export function Modal({
@ -28,9 +30,11 @@ export function Modal({
width,
titleSize,
hideDividerForTitle,
height,
noPadding,
icon,
hideCloseButton,
noScroll,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const [isMounted, setIsMounted] = useState(false);
@ -56,8 +60,10 @@ export function Modal({
const modalContent = (
<div
onMouseDown={handleMouseDown}
className={`fixed inset-0 bg-black bg-opacity-25 backdrop-blur-sm h-full
flex items-center justify-center z-[9999] transition-opacity duration-300 ease-in-out`}
className={cn(
`fixed inset-0 bg-black bg-opacity-25 backdrop-blur-sm h-full
flex items-center justify-center z-[9999] transition-opacity duration-300 ease-in-out`
)}
>
<div
ref={modalRef}
@ -93,8 +99,7 @@ export function Modal({
</button>
</div>
)}
<div className="w-full flex flex-col h-full justify-stretch">
<div className="w-full overflow-y-hidden flex flex-col h-full justify-stretch">
{title && (
<>
<div className="flex mb-4">
@ -110,7 +115,14 @@ export function Modal({
{!hideDividerForTitle && <Separator />}
</>
)}
<div className="max-h-[60vh] overflow-y-scroll">{children}</div>
<div
className={cn(
noScroll ? "overflow-auto" : "overflow-x-hidden",
height || "max-h-[60vh]"
)}
>
{children}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,65 @@
import { useState, useEffect } from "react";
import faviconFetch from "favicon-fetch";
import { SourceIcon } from "./SourceIcon";
const CACHE_DURATION = 24 * 60 * 60 * 1000;
export async function getFaviconUrl(url: string): Promise<string | null> {
const getCachedFavicon = () => {
const cachedData = localStorage.getItem(`favicon_${url}`);
if (cachedData) {
const { favicon, timestamp } = JSON.parse(cachedData);
if (Date.now() - timestamp < CACHE_DURATION) {
return favicon;
}
}
return null;
};
const cachedFavicon = getCachedFavicon();
if (cachedFavicon) {
return cachedFavicon;
}
const newFaviconUrl = await faviconFetch({ uri: url });
if (newFaviconUrl) {
localStorage.setItem(
`favicon_${url}`,
JSON.stringify({ favicon: newFaviconUrl, timestamp: Date.now() })
);
return newFaviconUrl;
}
return null;
}
export function SearchResultIcon({ url }: { url: string }) {
const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
useEffect(() => {
getFaviconUrl(url).then((favicon) => {
if (favicon) {
setFaviconUrl(favicon);
}
});
}, [url]);
if (!faviconUrl) {
return <SourceIcon sourceType="web" iconSize={18} />;
}
return (
<div className="rounded-full w-[18px] h-[18px] overflow-hidden bg-gray-200">
<img
height={18}
width={18}
className="rounded-full w-full h-full object-cover"
src={faviconUrl}
alt="favicon"
onError={(e) => {
e.currentTarget.onerror = null;
}}
/>
</div>
);
}

View File

@ -9,7 +9,7 @@ import { checkUserIsNoAuthUser, logout } from "@/lib/user";
import { Popover } from "./popover/Popover";
import { LOGOUT_DISABLED } from "@/lib/constants";
import { SettingsContext } from "./settings/SettingsProvider";
import { BellIcon, LightSettingsIcon } from "./icons/icons";
import { BellIcon, LightSettingsIcon, UserIcon } from "./icons/icons";
import { pageType } from "@/app/chat/sessionSidebar/types";
import { NavigationItem, Notification } from "@/app/admin/settings/interfaces";
import DynamicFaIcon, { preloadIcons } from "./icons/DynamicFaIcon";
@ -56,7 +56,13 @@ const DropdownOption: React.FC<DropdownOptionProps> = ({
}
};
export function UserDropdown({ page }: { page?: pageType }) {
export function UserDropdown({
page,
toggleUserSettings,
}: {
page?: pageType;
toggleUserSettings?: () => void;
}) {
const { user, isCurator } = useUser();
const [userInfoVisible, setUserInfoVisible] = useState(false);
const userInfoRef = useRef<HTMLDivElement>(null);
@ -238,6 +244,13 @@ export function UserDropdown({ page }: { page?: pageType }) {
)
)}
{toggleUserSettings && (
<DropdownOption
onClick={toggleUserSettings}
icon={<UserIcon className="h-5 w-5 my-auto mr-2" />}
label="User Settings"
/>
)}
<DropdownOption
onClick={() => {
setUserInfoVisible(true);

View File

@ -0,0 +1,16 @@
import { SourceIcon } from "./SourceIcon";
export function WebResultIcon({ url }: { url: string }) {
const hostname = new URL(url).hostname;
return hostname == "https://docs.danswer.dev" ? (
<img
className="my-0 py-0"
src={`https://www.google.com/s2/favicons?domain=${hostname}`}
alt="favicon"
height={18}
width={18}
/>
) : (
<SourceIcon sourceType="web" iconSize={18} />
);
}

View File

@ -40,14 +40,7 @@ export function AdminSidebar({ collections }: { collections: Collection[] }) {
<nav className="space-y-2">
<div className="w-full justify-center mb-4 flex">
<div className="w-52">
<Link
className="flex flex-col"
href={
settings && settings.default_page === "chat"
? "/chat"
: "/search"
}
>
<Link className="flex flex-col" href="/chat">
<div className="max-w-[200px] w-full flex gap-x-1 my-auto">
<div className="flex-none mb-auto">
<Logo />
@ -73,7 +66,7 @@ export function AdminSidebar({ collections }: { collections: Collection[] }) {
</div>
</div>
<div className="flex w-full justify-center">
<Link href={settings.default_page == "chat" ? "/chat" : "/search"}>
<Link href="/chat">
<button className="text-sm flex items-center block w-52 py-2.5 flex px-2 text-left text-text-back-button bg-background-back-button hover:bg-opacity-80 cursor-pointer rounded">
<BackIcon className="my-auto" size={18} />
<p className="ml-1 break-words line-clamp-2 ellipsis leading-none">

View File

@ -13,7 +13,9 @@ export default function AssistantBanner({
liveAssistant,
allAssistants,
onAssistantChange,
mobile = false,
}: {
mobile?: boolean;
recentAssistants: Persona[];
liveAssistant: Persona | undefined;
allAssistants: Persona[];
@ -35,13 +37,15 @@ export default function AssistantBanner({
)
)
// Take first 4
.slice(0, 4)
.slice(0, mobile ? 2 : 4)
.map((assistant) => (
<TooltipProvider key={assistant.id}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex w-36 mx-3 py-1.5 scale-[1.] rounded-full border border-background-150 justify-center items-center gap-x-2 py-1 px-3 hover:bg-background-125 transition-colors cursor-pointer"
className={`${
mobile ? "w-full" : "w-36 mx-3"
} flex py-1.5 scale-[1.] rounded-full border border-background-150 justify-center items-center gap-x-2 py-1 px-3 hover:bg-background-125 transition-colors cursor-pointer`}
onClick={() => onAssistantChange(assistant)}
>
<AssistantIcon

View File

@ -1,77 +1,85 @@
import { Persona } from "@/app/admin/assistants/interfaces";
import { AssistantTools } from "@/app/assistants/ToolsDisplay";
import { Bubble } from "@/components/Bubble";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { getDisplayNameForModel } from "@/lib/hooks";
import { useSortable } from "@dnd-kit/sortable";
import React, { useState } from "react";
import { FiBookmark } from "react-icons/fi";
import { FiBookmark, FiImage, FiSearch } from "react-icons/fi";
import { MdDragIndicator } from "react-icons/md";
import { Badge } from "../ui/badge";
export const AssistantCard = ({
assistant,
isSelected,
onSelect,
llmName,
}: {
assistant: Persona;
isSelected: boolean;
onSelect: (assistant: Persona) => void;
llmName: string;
}) => {
const [hovering, setHovering] = useState(false);
const renderBadgeContent = (tool: { name: string }) => {
switch (tool.name) {
case "SearchTool":
return (
<>
<FiSearch className="h-3 w-3 my-auto" />
<span>Search</span>
</>
);
case "ImageGenerationTool":
return (
<>
<FiImage className="h-3 w-3 my-auto" />
<span>Image Gen</span>
</>
);
default:
return tool.name;
}
};
return (
<div
onClick={() => onSelect(assistant)}
className={`
p-4
flex flex-col overflow-hidden w-full rounded-xl px-3 py-4
cursor-pointer
border
${isSelected ? "bg-hover" : "hover:bg-hover-light"}
shadow-md
rounded-lg
border-border
grow
flex items-center
overflow-hidden
${isSelected ? "bg-background-125" : "hover:bg-background-100"}
`}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<div className="w-full">
<div className="flex items-center mb-2">
<AssistantIcon assistant={assistant} />
<div className="ml-2 ellipsis truncate font-bold text-sm text-emphasis">
{assistant.name}
<div className="flex items-center gap-4">
<AssistantIcon size="xs" assistant={assistant} />
<div className="overflow-hidden text-ellipsis break-words flex-grow">
<div className="flex items-start justify-start gap-2">
<span className="line-clamp-1 text-sm text-black font-semibold leading-tight">
{assistant.name}
</span>
{assistant.tools.map((tool, index) => (
<Badge key={index} size="xs" variant="secondary" className="ml-1">
<div className="flex items-center gap-1">
{renderBadgeContent(tool)}
</div>
</Badge>
))}
</div>
</div>
<div className="text-xs text-wrap text-subtle mb-2 mt-2 line-clamp-3 py-1">
{assistant.description}
</div>
<div className="mt-2 flex flex-col gap-y-1">
{assistant.document_sets.length > 0 && (
<div className="text-xs text-subtle flex flex-wrap gap-2">
<p className="my-auto font-medium">Document Sets:</p>
{assistant.document_sets.map((set) => (
<Bubble key={set.id} isSelected={false}>
<div className="flex flex-row gap-1">
<FiBookmark className="mr-1 my-auto" />
{set.name}
</div>
</Bubble>
))}
</div>
)}
<div className="text-xs text-subtle">
<span className="font-semibold">Default model:</span>{" "}
{getDisplayNameForModel(
assistant.llm_model_version_override || llmName
)}
</div>
<AssistantTools hovered={hovering} assistant={assistant} />
<span className="line-clamp-2 text-xs text-text-700">
{assistant.description}
</span>
</div>
</div>
{assistant.document_sets.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{assistant.document_sets.map((set) => (
<Bubble key={set.id} isSelected={false}>
<div className="flex items-center gap-1 text-xs">
<FiBookmark className="text-text-500" />
{set.name}
</div>
</Bubble>
))}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,344 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useChatContext } from "@/components/context/ChatContext";
import { useUser } from "@/components/user/UserProvider";
import { Persona } from "@/app/admin/assistants/interfaces";
import { FiChevronDown } from "react-icons/fi";
import { destructureValue, getFinalLLM } from "@/lib/llm/utils";
import { updateModelOverrideForChatSession } from "@/app/chat/lib";
import { debounce } from "lodash";
import { LlmList } from "@/components/llm/LLMList";
import { checkPersonaRequiresImageGeneration } from "@/app/admin/assistants/lib";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { DraggableAssistantCard } from "@/components/assistants/AssistantCards";
import { updateUserAssistantList } from "@/lib/assistants/updateAssistantPreferences";
import Text from "@/components/ui/text";
import { LlmOverrideManager } from "@/lib/hooks";
import { Tab } from "@headlessui/react";
import { AssistantIcon } from "../assistants/AssistantIcon";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "../ui/drawer";
const AssistantSelector = ({
liveAssistant,
onAssistantChange,
chatSessionId,
llmOverrideManager,
isMobile,
}: {
liveAssistant: Persona;
onAssistantChange: (assistant: Persona) => void;
chatSessionId?: string;
llmOverrideManager?: LlmOverrideManager;
isMobile: boolean;
}) => {
const { finalAssistants } = useAssistants();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { llmProviders } = useChatContext();
const { user } = useUser();
const [assistants, setAssistants] = useState<Persona[]>(finalAssistants);
const [isTemperatureExpanded, setIsTemperatureExpanded] = useState(false);
const [localTemperature, setLocalTemperature] = useState<number>(
llmOverrideManager?.temperature || 0
);
// Initialize selectedTab from localStorage
const [selectedTab, setSelectedTab] = useState<number>(() => {
const storedTab = localStorage.getItem("assistantSelectorSelectedTab");
return storedTab !== null ? Number(storedTab) : 0;
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = assistants.findIndex(
(item) => item.id.toString() === active.id
);
const newIndex = assistants.findIndex(
(item) => item.id.toString() === over.id
);
const updatedAssistants = arrayMove(assistants, oldIndex, newIndex);
setAssistants(updatedAssistants);
await updateUserAssistantList(updatedAssistants.map((a) => a.id));
}
};
const debouncedSetTemperature = useCallback(
(value: number) => {
const debouncedFunction = debounce((value: number) => {
llmOverrideManager?.setTemperature(value);
}, 300);
return debouncedFunction(value);
},
[llmOverrideManager]
);
const handleTemperatureChange = (value: number) => {
setLocalTemperature(value);
debouncedSetTemperature(value);
};
// Handle tab change and update localStorage
const handleTabChange = (index: number) => {
setSelectedTab(index);
localStorage.setItem("assistantSelectorSelectedTab", index.toString());
};
// Get the user's default model
const userDefaultModel = user?.preferences.default_model;
const [_, currentLlm] = getFinalLLM(
llmProviders,
liveAssistant,
llmOverrideManager?.llmOverride ?? null
);
const requiresImageGeneration =
checkPersonaRequiresImageGeneration(liveAssistant);
const content = (
<>
<Tab.Group selectedIndex={selectedTab} onChange={handleTabChange}>
<Tab.List className="flex p-1 space-x-1 bg-gray-100 rounded-t-md">
<Tab
className={({ selected }) =>
`w-full py-2.5 text-sm leading-5 font-medium rounded-md
${
selected
? "bg-white text-gray-700 shadow"
: "text-gray-500 hover:bg-white/[0.12] hover:text-gray-700"
}`
}
>
Assistant
</Tab>
<Tab
className={({ selected }) =>
`w-full py-2.5 text-sm leading-5 font-medium rounded-md
${
selected
? "bg-white text-gray-700 shadow"
: "text-gray-500 hover:bg-white/[0.12] hover:text-gray-700"
}`
}
>
Model
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel className="p-3">
<div className="mb-4">
<h3 className="text-center text-lg font-semibold text-gray-800">
Choose an Assistant
</h3>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={assistants.map((a) => a.id.toString())}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 max-h-96 overflow-y-auto">
{assistants.map((assistant) => (
<DraggableAssistantCard
key={assistant.id.toString()}
assistant={assistant}
isSelected={liveAssistant.id === assistant.id}
onSelect={(assistant) => {
onAssistantChange(assistant);
setIsOpen(false);
}}
llmName={
assistant.llm_model_version_override ??
userDefaultModel ??
currentLlm
}
/>
))}
</div>
</SortableContext>
</DndContext>
</Tab.Panel>
<Tab.Panel className="p-3">
<div className="mb-4">
<h3 className="text-center text-lg font-semibold text-gray-800 ">
Choose a Model
</h3>
</div>
<LlmList
currentAssistant={liveAssistant}
requiresImageGeneration={requiresImageGeneration}
llmProviders={llmProviders}
currentLlm={currentLlm}
userDefault={userDefaultModel}
includeUserDefault={true}
onSelect={(value: string | null) => {
if (value == null) return;
const { modelName, name, provider } = destructureValue(value);
llmOverrideManager?.setLlmOverride({
name,
provider,
modelName,
});
if (chatSessionId) {
updateModelOverrideForChatSession(chatSessionId, value);
}
setIsOpen(false);
}}
/>
<div className="mt-4">
<button
className="flex items-center text-sm font-medium transition-colors duration-200"
onClick={() => setIsTemperatureExpanded(!isTemperatureExpanded)}
>
<span className="mr-2 text-xs text-primary">
{isTemperatureExpanded ? "▼" : "►"}
</span>
<span>Temperature</span>
</button>
{isTemperatureExpanded && (
<>
<Text className="mt-2 mb-8">
Adjust the temperature of the LLM. Higher temperatures will
make the LLM generate more creative and diverse responses,
while lower temperature will make the LLM generate more
conservative and focused responses.
</Text>
<div className="relative w-full">
<input
type="range"
onChange={(e) =>
handleTemperatureChange(parseFloat(e.target.value))
}
className="w-full p-2 border border-border rounded-md"
min="0"
max="2"
step="0.01"
value={localTemperature}
/>
<div
className="absolute text-sm"
style={{
left: `${(localTemperature || 0) * 50}%`,
transform: `translateX(-${Math.min(
Math.max((localTemperature || 0) * 50, 10),
90
)}%)`,
top: "-1.5rem",
}}
>
{localTemperature}
</div>
</div>
</>
)}
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</>
);
useEffect(() => {
if (!isMobile) {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isMobile]);
return (
<div className="pointer-events-auto relative" ref={dropdownRef}>
<div className="flex justify-center">
<div
onClick={() => {
setIsOpen(!isOpen);
// Get selectedTab from localStorage when opening
const storedTab = localStorage.getItem(
"assistantSelectorSelectedTab"
);
setSelectedTab(storedTab !== null ? Number(storedTab) : 0);
}}
className="flex items-center gap-x-2 justify-between px-6 py-3 text-sm font-medium text-white bg-black rounded-full shadow-lg hover:shadow-xl transition-all duration-300 cursor-pointer"
>
<div className="flex gap-x-2 items-center">
<AssistantIcon assistant={liveAssistant} size="xs" />
<span className="font-bold">{liveAssistant.name}</span>
</div>
<div className="flex items-center">
<span className="mr-2 text-xs">{currentLlm}</span>
<FiChevronDown
className={`w-5 h-5 text-white transition-transform duration-300 transform ${
isOpen ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</div>
</div>
</div>
{isMobile ? (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Assistant Selector</DrawerTitle>
</DrawerHeader>
{content}
</DrawerContent>
</Drawer>
) : (
isOpen && (
<div className="absolute z-10 w-96 mt-2 origin-top-center left-1/2 transform -translate-x-1/2 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{content}
</div>
)
)}
</div>
);
};
export default AssistantSelector;

View File

@ -11,7 +11,8 @@ import { pageType } from "@/app/chat/sessionSidebar/types";
import { useRouter } from "next/navigation";
import { ChatBanner } from "@/app/chat/ChatBanner";
import LogoType from "../header/LogoType";
import { useUser } from "../user/UserProvider";
import { Persona } from "@/app/admin/assistants/interfaces";
import { LlmOverrideManager } from "@/lib/hooks";
export default function FunctionalHeader({
page,
@ -20,13 +21,23 @@ export default function FunctionalHeader({
toggleSidebar = () => null,
reset = () => null,
sidebarToggled,
liveAssistant,
onAssistantChange,
llmOverrideManager,
documentSidebarToggled,
toggleUserSettings,
}: {
reset?: () => void;
page: pageType;
sidebarToggled?: boolean;
documentSidebarToggled?: boolean;
currentChatSession?: ChatSession | null | undefined;
setSharingModalVisible?: (value: SetStateAction<boolean>) => void;
toggleSidebar?: () => void;
liveAssistant?: Persona;
onAssistantChange?: (assistant: Persona) => void;
llmOverrideManager?: LlmOverrideManager;
toggleUserSettings?: () => void;
}) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@ -63,14 +74,15 @@ export default function FunctionalHeader({
router.push(newChatUrl);
};
return (
<div className="left-0 bg-transparent sticky top-0 z-20 w-full relative flex">
<div className="mt-2 mx-2.5 cursor-pointer text-text-700 relative flex w-full">
<div className="left-0 sticky top-0 z-20 w-full relative flex">
<div className="mt-2 cursor-pointer text-text-700 relative flex w-full">
<LogoType
assistantId={currentChatSession?.persona_id}
page={page}
toggleSidebar={toggleSidebar}
handleNewChat={handleNewChat}
/>
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
@ -85,6 +97,7 @@ export default function FunctionalHeader({
${sidebarToggled ? "w-[250px]" : "w-[0px]"}
`}
/>
<div className="w-full mobile:-mx-20 desktop:px-4">
<ChatBanner />
</div>
@ -108,7 +121,7 @@ export default function FunctionalHeader({
)}
<div className="mobile:hidden flex my-auto">
<UserDropdown />
<UserDropdown page={page} toggleUserSettings={toggleUserSettings} />
</div>
<Link
className="desktop:hidden my-auto"
@ -124,12 +137,37 @@ export default function FunctionalHeader({
<NewChatIcon size={20} />
</div>
</Link>
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
mobile:hidden
flex-none
mx-auto
overflow-y-hidden
transition-all
duration-300
ease-in-out
h-full
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
/>
</div>
</div>
{page != "assistants" && (
<div className="h-20 left-0 absolute top-0 z-10 w-full bg-gradient-to-b via-50% z-[-1] from-background via-background to-background/10 flex" />
)}
{page != "assistants" && (
<div
className={`
h-20 absolute top-0 z-10 w-full sm:w-[90%] lg:w-[70%]
bg-gradient-to-b via-50% z-[-1] from-background via-background to-background/10 flex
transition-all duration-300 ease-in-out
${
documentSidebarToggled
? "left-[200px] transform -translate-x-[calc(50%+100px)]"
: "left-1/2 transform -translate-x-1/2"
}
`}
/>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,78 @@
import { WebResultIcon } from "@/components/WebResultIcon";
import { SourceIcon } from "@/components/SourceIcon";
import { DanswerDocument } from "@/lib/search/interfaces";
export default function SourceCard({ doc }: { doc: DanswerDocument }) {
return (
<a
key={doc.document_id}
href={doc.link || undefined}
target="_blank"
rel="noopener"
className="flex flex-col gap-0.5 rounded-sm px-3 py-2.5 hover:bg-background-125 bg-background-100 w-[200px]"
>
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center gap-2 text-sm">
{doc.is_internet || doc.source_type === "web" ? (
<WebResultIcon url={doc.link} />
) : (
<SourceIcon sourceType={doc.source_type} iconSize={18} />
)}
<p>
{(doc.semantic_identifier || doc.document_id).slice(0, 12).trim()}
{(doc.semantic_identifier || doc.document_id).length > 12 && (
<span className="text-text-500">...</span>
)}
</p>
</div>
<div className="line-clamp-2 text-sm font-semibold"></div>
<div className="line-clamp-2 text-sm font-normal leading-snug text-text-700">
{doc.blurb}
</div>
</a>
);
}
interface SeeMoreBlockProps {
documentSelectionToggled: boolean;
toggleDocumentSelection?: () => void;
uniqueSources: DanswerDocument["source_type"][];
}
export function SeeMoreBlock({
documentSelectionToggled,
toggleDocumentSelection,
uniqueSources,
}: SeeMoreBlockProps) {
return (
<div
onClick={toggleDocumentSelection}
className={`
${documentSelectionToggled ? "border-border-100 border" : ""}
cursor-pointer w-[150px] rounded-sm flex-none transition-all duration-500 hover:bg-background-125 bg-text-100 px-3 py-2.5
`}
>
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center justify-between text-sm">
<p className="mr-0 flex-shrink-0">
{documentSelectionToggled ? "Hide sources" : "See context"}
</p>
<div className="flex -space-x-3 flex-shrink-0 overflow-hidden">
{uniqueSources.map((sourceType, ind) => (
<div
key={ind}
className="inline-block bg-background-100 rounded-full p-0.5"
style={{ zIndex: uniqueSources.length - ind }}
>
<div className="bg-background-100 rounded-full">
<SourceIcon sourceType={sourceType} iconSize={20} />
</div>
</div>
))}
</div>
</div>
<div className="line-clamp-2 text-sm font-semibold"></div>
<div className="line-clamp-2 text-sm font-normal leading-snug text-text-700">
See more
</div>
</div>
);
}

View File

@ -1,7 +1,13 @@
"use client";
import React, { createContext, useContext, useState } from "react";
import { DocumentSet, Tag, User, ValidSources } from "@/lib/types";
import {
CCPairBasicInfo,
DocumentSet,
Tag,
User,
ValidSources,
} from "@/lib/types";
import { ChatSession } from "@/app/chat/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
@ -12,6 +18,9 @@ import { personaComparator } from "@/app/admin/assistants/lib";
interface ChatContextProps {
chatSessions: ChatSession[];
availableSources: ValidSources[];
ccPairs: CCPairBasicInfo[];
tags: Tag[];
documentSets: DocumentSet[];
availableDocumentSets: DocumentSet[];
availableTags: Tag[];
llmProviders: LLMProviderDescriptor[];

View File

@ -8,9 +8,9 @@ import { ChatSession } from "@/app/chat/interfaces";
interface SearchContextProps {
querySessions: ChatSession[];
ccPairs: CCPairBasicInfo[];
tags: Tag[];
documentSets: DocumentSet[];
assistants: Persona[];
tags: Tag[];
agenticSearchEnabled: boolean;
disabledAgentic: boolean;
initiallyToggled: boolean;

View File

@ -81,7 +81,9 @@ import cohereIcon from "../../../public/Cohere.svg";
import voyageIcon from "../../../public/Voyage.png";
import googleIcon from "../../../public/Google.webp";
import xenforoIcon from "../../../public/Xenforo.svg";
import { FaRobot } from "react-icons/fa";
import { FaGithub, FaRobot } from "react-icons/fa";
import { isConstructSignatureDeclaration } from "typescript";
import { cn } from "@/lib/utils";
export interface IconProps {
size?: number;
@ -474,13 +476,6 @@ export const XSquareIcon = ({
return <XSquare size={size} className={className} />;
};
export const GlobeIcon = ({
size = 16,
className = defaultTailwindCSSBlue,
}: IconProps) => {
return <FiGlobe size={size} className={className} />;
};
export const FileIcon = ({
size = 16,
className = defaultTailwindCSSBlue,
@ -1034,9 +1029,16 @@ export const GithubIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => (
<LogoIcon size={size} className={className} src="/Github.png" />
<FaGithub size={size} className={cn(className, "text-black")} />
);
export const GlobeIcon = ({
size = 16,
className = defaultTailwindCSSBlue,
}: IconProps) => {
return <FiGlobe size={size} className={className} />;
};
export const GmailIcon = ({
size = 16,
className = defaultTailwindCSS,
@ -2698,3 +2700,28 @@ export const DownloadCSVIcon = ({
</svg>
);
};
export const UserIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return (
<svg
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19.618 21.25c0-3.602-4.016-6.53-7.618-6.53c-3.602 0-7.618 2.928-7.618 6.53M12 11.456a4.353 4.353 0 1 0 0-8.706a4.353 4.353 0 0 0 0 8.706"
/>
</svg>
);
};

View File

@ -1,10 +1,15 @@
import React from "react";
import { getDisplayNameForModel } from "@/lib/hooks";
import { checkLLMSupportsImageInput, structureValue } from "@/lib/llm/utils";
import {
checkLLMSupportsImageInput,
destructureValue,
structureValue,
} from "@/lib/llm/utils";
import {
getProviderIcon,
LLMProviderDescriptor,
} from "@/app/admin/configuration/llm/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
interface LlmListProps {
llmProviders: LLMProviderDescriptor[];
@ -14,15 +19,19 @@ interface LlmListProps {
scrollable?: boolean;
hideProviderIcon?: boolean;
requiresImageGeneration?: boolean;
includeUserDefault?: boolean;
currentAssistant?: Persona;
}
export const LlmList: React.FC<LlmListProps> = ({
currentAssistant,
llmProviders,
currentLlm,
onSelect,
userDefault,
scrollable,
requiresImageGeneration,
includeUserDefault = false,
}) => {
const llmOptionsByProvider: {
[provider: string]: {
@ -68,21 +77,6 @@ export const LlmList: React.FC<LlmListProps> = ({
: "max-h-[300px]"
} bg-background-175 flex flex-col gap-y-1 overflow-y-scroll`}
>
{userDefault && (
<button
type="button"
key={-1}
className={`w-full py-1.5 px-2 text-sm ${
currentLlm == null
? "bg-background-200"
: "bg-background hover:bg-background-100"
} text-left rounded`}
onClick={() => onSelect(null)}
>
User Default (currently {getDisplayNameForModel(userDefault)})
</button>
)}
{llmOptions.map(({ name, icon, value }, index) => {
if (!requiresImageGeneration || checkLLMSupportsImageInput(name)) {
return (
@ -98,6 +92,25 @@ export const LlmList: React.FC<LlmListProps> = ({
>
{icon({ size: 16 })}
{getDisplayNameForModel(name)}
{(() => {
if (
currentAssistant?.llm_model_version_override === name &&
userDefault &&
name === destructureValue(userDefault).modelName
) {
return " (assistant + user default)";
} else if (
currentAssistant?.llm_model_version_override === name
) {
return " (assistant)";
} else if (
userDefault &&
name === destructureValue(userDefault).modelName
) {
return " (user default)";
}
return "";
})()}
</button>
);
}

View File

@ -1,8 +1,9 @@
"use client";
import React from "react";
import {
DanswerDocument,
DocumentRelevance,
LoadedDanswerDocument,
SearchDanswerDocument,
} from "@/lib/search/interfaces";
import { DocumentFeedbackBlock } from "./DocumentFeedbackBlock";
@ -11,19 +12,21 @@ import { PopupSpec } from "../admin/connectors/Popup";
import { DocumentUpdatedAtBadge } from "./DocumentUpdatedAtBadge";
import { SourceIcon } from "../SourceIcon";
import { MetadataBadge } from "../MetadataBadge";
import { BookIcon, LightBulbIcon } from "../icons/icons";
import { BookIcon, GlobeIcon, LightBulbIcon, SearchIcon } from "../icons/icons";
import { FaStar } from "react-icons/fa";
import { FiTag } from "react-icons/fi";
import { SettingsContext } from "../settings/SettingsProvider";
import { CustomTooltip, TooltipGroup } from "../tooltip/CustomTooltip";
import { WarningCircle } from "@phosphor-icons/react";
import { SearchResultIcon } from "../SearchResultIcon";
export const buildDocumentSummaryDisplay = (
matchHighlights: string[],
blurb: string
) => {
if (matchHighlights.length === 0) {
if (!matchHighlights || matchHighlights.length === 0) {
// console.log("no match highlights", matchHighlights);
return blurb;
}
@ -251,7 +254,11 @@ export const DocumentDisplay = ({
>
<CustomTooltip showTick line content="Toggle content">
<LightBulbIcon
className={`${settings?.isMobile && alternativeToggled ? "text-green-600" : "text-blue-600"} my-auto ml-2 h-4 w-4 cursor-pointer`}
className={`${
settings?.isMobile && alternativeToggled
? "text-green-600"
: "text-blue-600"
} my-auto ml-2 h-4 w-4 cursor-pointer`}
/>
</CustomTooltip>
</button>
@ -308,7 +315,9 @@ export const AgenticDocumentDisplay = ({
}}
>
<div
className={`collapsible ${!hide && "collapsible-closed overflow-y-auto border-transparent"}`}
className={`collapsible ${
!hide && "collapsible-closed overflow-y-auto border-transparent"
}`}
>
<div className="flex relative">
<a
@ -380,3 +389,38 @@ export const AgenticDocumentDisplay = ({
</div>
);
};
export function CompactDocumentCard({
document,
icon,
url,
}: {
document: LoadedDanswerDocument;
icon?: React.ReactNode;
url?: string;
}) {
return (
<div className="max-w-[250px] pb-0 pt-0 mt-0 flex gap-y-0 flex-col content-start items-start gap-0 ">
<h3 className="text-sm font-semibold flex items-center gap-x-1 text-text-900 pt-0 mt-0 truncate w-full">
{icon}
{(document.semantic_identifier || document.document_id).slice(0, 40)}
{(document.semantic_identifier || document.document_id).length > 40 &&
"..."}
</h3>
{document.blurb && (
<p className="text-xs mb-0 text-gray-600 line-clamp-2">
{document.blurb}
</p>
)}
{document.updated_at && (
<div className=" flex mt-0 pt-0 items-center justify-between w-full ">
{!isNaN(new Date(document.updated_at).getTime()) && (
<span className="text-xs text-gray-500">
Updated {new Date(document.updated_at).toLocaleDateString()}
</span>
)}
</div>
)}
</div>
);
}

View File

@ -1,6 +1,17 @@
import { timeAgo } from "@/lib/time";
import { MetadataBadge } from "../MetadataBadge";
export function DocumentUpdatedAtBadge({ updatedAt }: { updatedAt: string }) {
return <MetadataBadge value={"Updated " + timeAgo(updatedAt)} />;
export function DocumentUpdatedAtBadge({
updatedAt,
modal,
}: {
updatedAt: string;
modal?: boolean;
}) {
return (
<MetadataBadge
flexNone={modal}
value={(modal ? "" : "Updated ") + timeAgo(updatedAt)}
/>
);
}

View File

@ -1,169 +0,0 @@
"use client";
import {
Dispatch,
SetStateAction,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { HoverableIcon } from "../Hoverable";
import {
DislikeFeedbackIcon,
LikeFeedbackIcon,
ToggleDown,
} from "../icons/icons";
import { FeedbackType } from "@/app/chat/types";
import { searchState } from "./SearchSection";
import { SettingsContext } from "../settings/SettingsProvider";
import { AnswerSection } from "./results/AnswerSection";
import { Quote, SearchResponse } from "@/lib/search/interfaces";
import { QuotesSection } from "./results/QuotesSection";
export default function SearchAnswer({
searchAnswerExpanded,
setSearchAnswerExpanded,
isFetching,
dedupedQuotes,
searchResponse,
setCurrentFeedback,
searchState,
}: {
searchAnswerExpanded: boolean;
setSearchAnswerExpanded: Dispatch<SetStateAction<boolean>>;
isFetching: boolean;
dedupedQuotes: Quote[];
searchResponse: SearchResponse;
searchState: searchState;
setCurrentFeedback: Dispatch<SetStateAction<[FeedbackType, number] | null>>;
}) {
const [searchAnswerOverflowing, setSearchAnswerOverflowing] = useState(false);
const { quotes, answer, error } = searchResponse;
const answerContainerRef = useRef<HTMLDivElement>(null);
const handleFeedback = (feedbackType: FeedbackType, messageId: number) => {
setCurrentFeedback([feedbackType, messageId]);
};
const settings = useContext(SettingsContext);
useEffect(() => {
const checkOverflow = () => {
if (answerContainerRef.current) {
const isOverflowing =
answerContainerRef.current.scrollHeight >
answerContainerRef.current.clientHeight;
setSearchAnswerOverflowing(isOverflowing);
}
};
checkOverflow();
window.addEventListener("resize", checkOverflow);
return () => {
window.removeEventListener("resize", checkOverflow);
};
}, [answer, quotes]);
return (
<div
ref={answerContainerRef}
className={`my-4 ${
searchAnswerExpanded ? "min-h-[16rem]" : "h-[16rem]"
} ${
!searchAnswerExpanded && searchAnswerOverflowing && "overflow-y-hidden"
} p-4 border-2 border-search-answer-border rounded-lg relative`}
>
<div>
<div className="flex gap-x-2">
<h2 className="text-emphasis font-bold my-auto mb-1">AI Answer</h2>
{searchState == "generating" && (
<div key={"generating"} className="relative inline-block">
<span className="loading-text">Generating Response...</span>
</div>
)}
{searchState == "citing" && (
<div key={"citing"} className="relative inline-block">
<span className="loading-text">Extracting Quotes...</span>
</div>
)}
{searchState == "searching" && (
<div key={"Reading"} className="relative inline-block">
<span className="loading-text">Searching...</span>
</div>
)}
{searchState == "reading" && (
<div key={"Reading"} className="relative inline-block">
<span className="loading-text">
Reading{settings?.isMobile ? "" : " Documents"}
...
</span>
</div>
)}
{searchState == "analyzing" && (
<div key={"Generating"} className="relative inline-block">
<span className="loading-text">
Running
{settings?.isMobile ? "" : " Analysis"}...
</span>
</div>
)}
</div>
<div className={`pt-1 h-auto border-t border-border w-full`}>
<AnswerSection
answer={answer}
quotes={quotes}
error={error}
isFetching={isFetching}
/>
</div>
<div className="w-full">
{quotes !== null && quotes.length > 0 && answer && (
<QuotesSection quotes={dedupedQuotes} isFetching={isFetching} />
)}
{searchResponse.messageId !== null && (
<div className="absolute right-3 flex bottom-3">
<HoverableIcon
icon={<LikeFeedbackIcon />}
onClick={() =>
handleFeedback("like", searchResponse?.messageId as number)
}
/>
<HoverableIcon
icon={<DislikeFeedbackIcon />}
onClick={() =>
handleFeedback("dislike", searchResponse?.messageId as number)
}
/>
</div>
)}
</div>
</div>
{!searchAnswerExpanded && searchAnswerOverflowing && (
<div className="absolute bottom-0 left-0 w-full h-[100px] bg-gradient-to-b from-background/5 via-background/60 to-background/90"></div>
)}
{!searchAnswerExpanded && searchAnswerOverflowing && (
<div className="w-full h-12 absolute items-center content-center flex left-0 px-4 bottom-0">
<button
onClick={() => setSearchAnswerExpanded(true)}
className="flex gap-x-1 items-center justify-center hover:bg-background-100 cursor-pointer max-w-sm text-sm mx-auto w-full bg-background border py-2 rounded-full"
>
Show more
<ToggleDown />
</button>
</div>
)}
</div>
);
}

View File

@ -17,6 +17,12 @@ interface FullSearchBarProps {
showingSidebar: boolean;
}
import {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useRef } from "react";
import { SendIcon } from "../icons/icons";
import { Separator } from "@/components/ui/separator";
@ -28,61 +34,65 @@ import { CCPairBasicInfo, DocumentSet, Tag } from "@/lib/types";
export const AnimatedToggle = ({
isOn,
handleToggle,
direction = "top",
}: {
isOn: boolean;
handleToggle: () => void;
direction?: "bottom" | "top";
}) => {
const commandSymbol = KeyboardSymbol();
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
return (
<CustomTooltip
light
large
content={
<div className="bg-white my-auto p-6 rounded-lg w-full">
<h2 className="text-xl text-text-800 font-bold mb-2">
Agentic Search
</h2>
<p className="text-text-700 text-sm mb-4">
Our most powerful search, have an AI agent guide you to pinpoint
exactly what you&apos;re looking for.
</p>
<Separator />
<h2 className="text-xl text-text-800 font-bold mb-2">Fast Search</h2>
<p className="text-text-700 text-sm mb-4">
Get quality results immediately, best suited for instant access to
your documents.
</p>
<p className="mt-2 flex text-xs">Shortcut: ({commandSymbol}/)</p>
</div>
}
>
<div
ref={containerRef}
className="my-auto ml-auto flex justify-end items-center cursor-pointer"
onClick={handleToggle}
>
<div ref={contentRef} className="flex items-center">
{/* Toggle switch */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`
w-10 h-6 flex items-center rounded-full p-1 transition-all duration-300 ease-in-out
${isOn ? "bg-toggled-background" : "bg-untoggled-background"}
`}
ref={containerRef}
className="my-auto ml-auto flex justify-end items-center cursor-pointer"
onClick={handleToggle}
>
<div
className={`
bg-white w-4 h-4 rounded-full shadow-md transform transition-all duration-300 ease-in-out
${isOn ? "translate-x-4" : ""}
`}
></div>
<div ref={contentRef} className="flex items-center">
<div
className={`
w-10 h-6 flex items-center rounded-full p-1 transition-all duration-300 ease-in-out
${isOn ? "bg-toggled-background" : "bg-untoggled-background"}
`}
>
<div
className={`
bg-white w-4 h-4 rounded-full shadow-md transform transition-all duration-300 ease-in-out
${isOn ? "translate-x-4" : ""}
`}
></div>
</div>
<p className="ml-2 text-sm">Pro</p>
</div>
</div>
<p className="ml-2 text-sm">Agentic</p>
</div>
</div>
</CustomTooltip>
</TooltipTrigger>
<TooltipContent side={direction} backgroundColor="bg-background-200">
<div className="bg-white my-auto p-6 rounded-lg max-w-sm">
<h2 className="text-xl text-text-800 font-bold mb-2">
Agentic Search
</h2>
<p className="text-text-700 text-sm mb-4">
Our most powerful search, have an AI agent guide you to pinpoint
exactly what you&apos;re looking for.
</p>
<Separator />
<h2 className="text-xl text-text-800 font-bold mb-2">
Fast Search
</h2>
<p className="text-text-700 text-sm mb-4">
Get quality results immediately, best suited for instant access to
your documents.
</p>
<p className="mt-2 flex text-xs">Shortcut: ({commandSymbol}/)</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@ -1,301 +0,0 @@
"use client";
import {
DocumentRelevance,
SearchDanswerDocument,
SearchDefaultOverrides,
SearchResponse,
} from "@/lib/search/interfaces";
import { usePopup } from "../admin/connectors/Popup";
import { AlertIcon, MagnifyingIcon, UndoIcon } from "../icons/icons";
import { AgenticDocumentDisplay, DocumentDisplay } from "./DocumentDisplay";
import { searchState } from "./SearchSection";
import { useEffect, useState } from "react";
import {
Tooltip,
TooltipProvider,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import KeyboardSymbol from "@/lib/browserUtilities";
import { DISABLE_LLM_DOC_RELEVANCE } from "@/lib/constants";
const getSelectedDocumentIds = (
documents: SearchDanswerDocument[],
selectedIndices: number[]
) => {
const selectedDocumentIds = new Set<string>();
selectedIndices.forEach((ind) => {
selectedDocumentIds.add(documents[ind].document_id);
});
return selectedDocumentIds;
};
export const SearchResultsDisplay = ({
agenticResults,
searchResponse,
contentEnriched,
disabledAgentic,
isFetching,
defaultOverrides,
performSweep,
searchState,
sweep,
}: {
searchState: searchState;
disabledAgentic?: boolean;
contentEnriched?: boolean;
agenticResults?: boolean | null;
performSweep: () => void;
sweep?: boolean;
searchResponse: SearchResponse | null;
isFetching: boolean;
defaultOverrides: SearchDefaultOverrides;
comments: any;
}) => {
const commandSymbol = KeyboardSymbol();
const { popup, setPopup } = usePopup();
const [showAll, setShowAll] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
switch (event.key.toLowerCase()) {
case "o":
event.preventDefault();
performSweep();
if (agenticResults) {
setShowAll((showAll) => !showAll);
}
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [performSweep, agenticResults]);
if (!searchResponse) {
return null;
}
const { answer, quotes, documents, error, messageId } = searchResponse;
if (isFetching && disabledAgentic) {
return (
<div className="mt-4">
<div className="font-bold flex justify-between text-emphasis border-b mb-3 pb-1 border-border text-lg">
<p>Results</p>
</div>
</div>
);
}
if (isFetching && !answer && !documents) {
return null;
}
if (documents != null && documents.length == 0 && searchState == "input") {
return (
<div className="text-base gap-x-1.5 flex flex-col">
<div className="flex gap-x-2 items-center font-semibold">
<AlertIcon size={16} />
No documents were found!
</div>
<p>
Have you set up a connector? Your data may not have loaded properly.
</p>
</div>
);
}
if (
answer === null &&
(documents === null || documents.length === 0) &&
!isFetching
) {
return (
<div className="mt-4">
{error && (
<div className="text-error text-sm">
<div className="flex">
<AlertIcon size={16} className="text-error my-auto mr-1" />
<p className="italic">{error || "No documents were found!"}</p>
</div>
</div>
)}
</div>
);
}
const selectedDocumentIds = getSelectedDocumentIds(
documents || [],
searchResponse.selectedDocIndices || []
);
const relevantDocs = documents
? documents.filter((doc) => {
return (
showAll ||
(searchResponse &&
searchResponse.additional_relevance &&
searchResponse.additional_relevance[doc.document_id] &&
searchResponse.additional_relevance[doc.document_id].relevant) ||
doc.is_relevant
);
})
: [];
const getUniqueDocuments = (
documents: SearchDanswerDocument[]
): SearchDanswerDocument[] => {
const seenIds = new Set<string>();
return documents.filter((doc) => {
if (!seenIds.has(doc.document_id)) {
seenIds.add(doc.document_id);
return true;
}
return false;
});
};
const uniqueDocuments = getUniqueDocuments(documents || []);
return (
<>
{popup}
{documents && documents.length > 0 && (
<div className="mt-4">
<div className="font-bold flex h-12 justify-between text-emphasis border-b mb-3 pb-1 border-border text-lg">
<p>Results</p>
{!DISABLE_LLM_DOC_RELEVANCE &&
(contentEnriched || searchResponse.additional_relevance) && (
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
performSweep();
if (agenticResults) {
setShowAll((showAll) => !showAll);
}
}}
className={`flex items-center justify-center animate-fade-in-up rounded-lg p-1 text-xs transition-all duration-300 w-20 h-8 ${
!sweep
? "bg-background-agentic-toggled text-text-agentic-toggled"
: "bg-background-agentic-untoggled text-text-agentic-untoggled"
}`}
style={{
transform: sweep
? "rotateZ(180deg)"
: "rotateZ(0deg)",
}}
>
<div
className={`flex items-center ${
sweep ? "rotate-180" : ""
}`}
>
<span></span>
{!sweep
? agenticResults
? "Show All"
: "Focus"
: agenticResults
? "Focus"
: "Show All"}
<span className="ml-1">
{!sweep ? (
<MagnifyingIcon className="h-4 w-4" />
) : (
<UndoIcon className="h-4 w-4" />
)}
</span>
</div>
</button>
</TooltipTrigger>
<TooltipContent>
<div className="flex">{commandSymbol}O</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{agenticResults &&
relevantDocs &&
contentEnriched &&
relevantDocs.length == 0 &&
!showAll && (
<p className="flex text-lg font-bold">
No high quality results found by agentic search.
</p>
)}
{uniqueDocuments.map((document, ind) => {
const relevance: DocumentRelevance | null =
searchResponse.additional_relevance
? searchResponse.additional_relevance[document.document_id]
: null;
return agenticResults ? (
<AgenticDocumentDisplay
additional_relevance={relevance}
contentEnriched={contentEnriched}
index={ind}
hide={showAll || relevance?.relevant || document.is_relevant}
key={`${document.document_id}-${ind}`}
document={document}
documentRank={ind + 1}
messageId={messageId}
isSelected={selectedDocumentIds.has(document.document_id)}
setPopup={setPopup}
/>
) : (
<DocumentDisplay
additional_relevance={relevance}
contentEnriched={contentEnriched}
index={ind}
hide={sweep && !document.is_relevant && !relevance?.relevant}
key={`${document.document_id}-${ind}`}
document={document}
documentRank={ind + 1}
messageId={messageId}
isSelected={selectedDocumentIds.has(document.document_id)}
setPopup={setPopup}
/>
);
})}
</div>
)}
<div className="h-[100px]" />
</>
);
};
export function AgenticDisclaimer({
forceNonAgentic,
}: {
forceNonAgentic: () => void;
}) {
return (
<div className="ml-auto mx-12 flex transition-all duration-300 animate-fade-in flex-col gap-y-2">
<p className="text-sm">
Please note that agentic quries can take substantially longer than
non-agentic queries. You can click <i>non-agentic</i> to re-submit your
query without agentic capabilities.
</p>
<button
onClick={forceNonAgentic}
className="p-2 bg-background-900 mr-auto text-text-200 rounded-lg text-xs my-auto"
>
Non-agentic
</button>
</div>
);
}

View File

@ -1,856 +0,0 @@
"use client";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { FullSearchBar } from "./SearchBar";
import { SearchResultsDisplay } from "./SearchResultsDisplay";
import { SourceSelector } from "./filtering/Filters";
import {
Quote,
SearchResponse,
FlowType,
SearchType,
SearchDefaultOverrides,
SearchRequestOverrides,
ValidQuestionResponse,
Relevance,
SearchDanswerDocument,
SourceMetadata,
} from "@/lib/search/interfaces";
import { searchRequestStreamed } from "@/lib/search/streamingQa";
import { CancellationToken, cancellable } from "@/lib/search/cancellable";
import { useFilters, useObjectState } from "@/lib/hooks";
import { Persona } from "@/app/admin/assistants/interfaces";
import { computeAvailableFilters } from "@/lib/filters";
import { useRouter, useSearchParams } from "next/navigation";
import { SettingsContext } from "../settings/SettingsProvider";
import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar";
import { ChatSession, SearchSession } from "@/app/chat/interfaces";
import FunctionalHeader from "../chat_search/Header";
import { useSidebarVisibility } from "../chat_search/hooks";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "../resizable/constants";
import { AGENTIC_SEARCH_TYPE_COOKIE_NAME } from "@/lib/constants";
import Cookies from "js-cookie";
import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo";
import { usePopup } from "../admin/connectors/Popup";
import { FeedbackType } from "@/app/chat/types";
import { FeedbackModal } from "@/app/chat/modal/FeedbackModal";
import { deleteChatSession, handleChatFeedback } from "@/app/chat/lib";
import SearchAnswer from "./SearchAnswer";
import { DeleteEntityModal } from "../modals/DeleteEntityModal";
import { ApiKeyModal } from "../llm/ApiKeyModal";
import { useSearchContext } from "../context/SearchContext";
import { useUser } from "../user/UserProvider";
import UnconfiguredProviderText from "../chat_search/UnconfiguredProviderText";
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
import { Tag } from "@/lib/types";
import { isEqual } from "lodash";
export type searchState =
| "input"
| "searching"
| "reading"
| "analyzing"
| "summarizing"
| "generating"
| "citing";
const SEARCH_DEFAULT_OVERRIDES_START: SearchDefaultOverrides = {
forceDisplayQA: false,
offset: 0,
};
interface SearchSectionProps {
toggle: () => void;
defaultSearchType: SearchType;
toggledSidebar: boolean;
}
export const SearchSection = ({
toggle,
toggledSidebar,
defaultSearchType,
}: SearchSectionProps) => {
const {
querySessions,
ccPairs,
documentSets,
assistants,
tags,
shouldShowWelcomeModal,
agenticSearchEnabled,
disabledAgentic,
shouldDisplayNoSources,
} = useSearchContext();
const [query, setQuery] = useState<string>("");
const [comments, setComments] = useState<any>(null);
const [contentEnriched, setContentEnriched] = useState(false);
const [searchResponse, setSearchResponse] = useState<SearchResponse>({
suggestedSearchType: null,
suggestedFlowType: null,
answer: null,
quotes: null,
documents: null,
selectedDocIndices: null,
error: null,
messageId: null,
});
const [showApiKeyModal, setShowApiKeyModal] = useState(true);
const [agentic, setAgentic] = useState(agenticSearchEnabled);
const toggleAgentic = useCallback(() => {
Cookies.set(
AGENTIC_SEARCH_TYPE_COOKIE_NAME,
String(!agentic).toLocaleLowerCase()
);
setAgentic((agentic) => !agentic);
}, [agentic]);
const toggleSidebar = useCallback(() => {
Cookies.set(
SIDEBAR_TOGGLED_COOKIE_NAME,
String(!toggledSidebar).toLocaleLowerCase()
),
{
path: "/",
};
toggle();
}, [toggledSidebar, toggle]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
switch (event.key.toLowerCase()) {
case "/":
toggleAgentic();
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [toggleAgentic]);
const [isFetching, setIsFetching] = useState(false);
// Search Type
const selectedSearchType = defaultSearchType;
// If knowledge assistant exists, use it. Otherwise, use first available assistant for search.
const selectedPersona = assistants.find((assistant) => assistant.id === 0)
? 0
: assistants[0]?.id;
// Used for search state display
const [analyzeStartTime, setAnalyzeStartTime] = useState<number>(0);
// Filters
const filterManager = useFilters();
const availableSources = ccPairs.map((ccPair) => ccPair.source);
const [finalAvailableSources, finalAvailableDocumentSets] =
computeAvailableFilters({
selectedPersona: assistants.find(
(assistant) => assistant.id === selectedPersona
),
availableSources: availableSources,
availableDocumentSets: documentSets,
});
const searchParams = useSearchParams();
const existingSearchessionId = searchParams.get("searchId");
useEffect(() => {
if (existingSearchessionId == null) {
return;
}
function extractFirstMessageByType(
chatSession: SearchSession,
messageType: "user" | "assistant"
): string | null {
const userMessage = chatSession?.messages.find(
(msg) => msg.message_type === messageType
);
return userMessage ? userMessage.message : null;
}
async function initialSessionFetch() {
const response = await fetch(
`/api/query/search-session/${existingSearchessionId}`
);
const searchSession = (await response.json()) as SearchSession;
const userMessage = extractFirstMessageByType(searchSession, "user");
const assistantMessage = extractFirstMessageByType(
searchSession,
"assistant"
);
if (userMessage) {
setQuery(userMessage);
const danswerDocs: SearchResponse = {
documents: searchSession.documents,
suggestedSearchType: null,
answer: assistantMessage || "Search response not found",
quotes: null,
selectedDocIndices: null,
error: null,
messageId: searchSession.messages[0].message_id,
suggestedFlowType: null,
additional_relevance: undefined,
};
setIsFetching(false);
setFirstSearch(false);
setSearchResponse(danswerDocs);
setContentEnriched(true);
}
}
initialSessionFetch();
}, [existingSearchessionId]);
// Overrides for default behavior that only last a single query
const [defaultOverrides, setDefaultOverrides] =
useState<SearchDefaultOverrides>(SEARCH_DEFAULT_OVERRIDES_START);
const newSearchState = (
currentSearchState: searchState,
newSearchState: searchState
) => {
if (currentSearchState != "input") {
return newSearchState;
}
return "input";
};
// Helpers
const initialSearchResponse: SearchResponse = {
answer: null,
quotes: null,
documents: null,
suggestedSearchType: null,
suggestedFlowType: null,
selectedDocIndices: null,
error: null,
messageId: null,
additional_relevance: undefined,
};
// Streaming updates
const updateCurrentAnswer = (answer: string) => {
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
answer,
}));
if (analyzeStartTime) {
const elapsedTime = Date.now() - analyzeStartTime;
const nextInterval = Math.ceil(elapsedTime / 1500) * 1500;
setTimeout(() => {
setSearchState((searchState) =>
newSearchState(searchState, "generating")
);
}, nextInterval - elapsedTime);
}
};
const updateQuotes = (quotes: Quote[]) => {
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
quotes,
}));
setSearchState((searchState) => "citing");
};
const updateDocs = (documents: SearchDanswerDocument[]) => {
if (agentic) {
setTimeout(() => {
setSearchState((searchState) => newSearchState(searchState, "reading"));
}, 1500);
setTimeout(() => {
setAnalyzeStartTime(Date.now());
setSearchState((searchState) => {
const newState = newSearchState(searchState, "analyzing");
if (newState === "analyzing") {
setAnalyzeStartTime(Date.now());
}
return newState;
});
}, 4500);
}
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
documents,
}));
if (disabledAgentic) {
setIsFetching(false);
setSearchState((searchState) => "citing");
}
if (documents.length == 0) {
setSearchState("input");
}
};
const updateSuggestedSearchType = (suggestedSearchType: SearchType) =>
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
suggestedSearchType,
}));
const updateSuggestedFlowType = (suggestedFlowType: FlowType) =>
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
suggestedFlowType,
}));
const updateSelectedDocIndices = (docIndices: number[]) =>
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
selectedDocIndices: docIndices,
}));
const updateError = (error: FlowType) => {
resetInput(true);
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
error,
}));
};
const updateMessageAndThreadId = (
messageId: number,
chat_session_id: string
) => {
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
messageId,
}));
router.refresh();
setIsFetching(false);
setSearchState((searchState) => "input");
};
const updateDocumentRelevance = (relevance: Relevance) => {
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
additional_relevance: relevance,
}));
setContentEnriched(true);
setIsFetching(false);
if (disabledAgentic) {
setSearchState("input");
} else {
setSearchState("analyzing");
}
};
const updateComments = (comments: any) => {
setComments(comments);
};
const finishedSearching = () => {
if (disabledAgentic) {
setSearchState("input");
}
};
const { user } = useUser();
const [searchAnswerExpanded, setSearchAnswerExpanded] = useState(false);
const resetInput = (finalized?: boolean) => {
setSweep(false);
setFirstSearch(false);
setComments(null);
setSearchState(finalized ? "input" : "searching");
setSearchAnswerExpanded(false);
};
interface SearchDetails {
query: string;
sources: SourceMetadata[];
agentic: boolean;
documentSets: string[];
timeRange: DateRangePickerValue | null;
tags: Tag[];
persona: Persona;
}
const [previousSearch, setPreviousSearch] = useState<null | SearchDetails>(
null
);
const [agenticResults, setAgenticResults] = useState<boolean | null>(null);
const currentSearch = (overrideMessage?: string): SearchDetails => {
return {
query: overrideMessage || query,
sources: filterManager.selectedSources,
agentic: agentic!,
documentSets: filterManager.selectedDocumentSets,
timeRange: filterManager.timeRange,
tags: filterManager.selectedTags,
persona: assistants.find(
(assistant) => assistant.id === selectedPersona
) as Persona,
};
};
const isSearchChanged = () => {
return !isEqual(currentSearch(), previousSearch);
};
let lastSearchCancellationToken = useRef<CancellationToken | null>(null);
const onSearch = async ({
searchType,
agentic,
offset,
overrideMessage,
}: SearchRequestOverrides = {}) => {
if ((overrideMessage || query) == "") {
return;
}
setAgenticResults(agentic!);
resetInput();
setContentEnriched(false);
if (lastSearchCancellationToken.current) {
lastSearchCancellationToken.current.cancel();
}
lastSearchCancellationToken.current = new CancellationToken();
setIsFetching(true);
setSearchResponse(initialSearchResponse);
setPreviousSearch(currentSearch(overrideMessage));
const searchFnArgs = {
query: overrideMessage || query,
sources: filterManager.selectedSources,
agentic: agentic,
documentSets: filterManager.selectedDocumentSets,
timeRange: filterManager.timeRange,
tags: filterManager.selectedTags,
persona: assistants.find(
(assistant) => assistant.id === selectedPersona
) as Persona,
updateCurrentAnswer: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateCurrentAnswer,
}),
updateQuotes: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateQuotes,
}),
updateDocs: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateDocs,
}),
updateSuggestedSearchType: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateSuggestedSearchType,
}),
updateSuggestedFlowType: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateSuggestedFlowType,
}),
updateSelectedDocIndices: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateSelectedDocIndices,
}),
updateError: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateError,
}),
updateMessageAndThreadId: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateMessageAndThreadId,
}),
updateDocStatus: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateMessageAndThreadId,
}),
updateDocumentRelevance: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateDocumentRelevance,
}),
updateComments: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateComments,
}),
finishedSearching: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: finishedSearching,
}),
selectedSearchType: searchType ?? selectedSearchType,
offset: offset ?? defaultOverrides.offset,
};
await Promise.all([searchRequestStreamed(searchFnArgs)]);
};
// handle redirect if search page is disabled
// NOTE: this must be done here, in a client component since
// settings are passed in via Context and therefore aren't
// available in server-side components
const router = useRouter();
const settings = useContext(SettingsContext);
if (settings?.settings?.search_page_enabled === false) {
router.push("/chat");
}
const sidebarElementRef = useRef<HTMLDivElement>(null);
const innerSidebarElementRef = useRef<HTMLDivElement>(null);
const [showDocSidebar, setShowDocSidebar] = useState(false);
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, toggleSidebar]);
useEffect(() => {
if (settings?.isMobile) {
router.push("/chat");
}
}, [settings?.isMobile, router]);
const handleTransitionEnd = (e: React.TransitionEvent<HTMLDivElement>) => {
if (e.propertyName === "opacity" && !firstSearch) {
const target = e.target as HTMLDivElement;
target.style.display = "none";
}
};
const [sweep, setSweep] = useState(false);
const performSweep = () => {
setSweep((sweep) => !sweep);
};
const [firstSearch, setFirstSearch] = useState(true);
const [searchState, setSearchState] = useState<searchState>("input");
const [deletingChatSession, setDeletingChatSession] =
useState<ChatSession | null>();
const showDeleteModal = (chatSession: ChatSession) => {
setDeletingChatSession(chatSession);
};
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
const [untoggled, setUntoggled] = useState(false);
const explicitlyUntoggle = () => {
setShowDocSidebar(false);
setUntoggled(true);
setTimeout(() => {
setUntoggled(false);
}, 200);
};
useSidebarVisibility({
toggledSidebar,
sidebarElementRef,
showDocSidebar,
setShowDocSidebar,
mobile: settings?.isMobile,
});
const { answer, quotes, documents, error, messageId } = searchResponse;
const dedupedQuotes: Quote[] = [];
const seen = new Set<string>();
if (quotes) {
quotes.forEach((quote) => {
if (!seen.has(quote.document_id)) {
dedupedQuotes.push(quote);
seen.add(quote.document_id);
}
});
}
const [currentFeedback, setCurrentFeedback] = useState<
[FeedbackType, number] | null
>(null);
const onFeedback = async (
messageId: number,
feedbackType: FeedbackType,
feedbackDetails: string,
predefinedFeedback: string | undefined
) => {
const response = await handleChatFeedback(
messageId,
feedbackType,
feedbackDetails,
predefinedFeedback
);
if (response.ok) {
setPopup({
message: "Thanks for your feedback!",
type: "success",
});
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to submit feedback - ${errorMsg}`,
type: "error",
});
}
};
const chatBannerPresent = settings?.enterpriseSettings?.custom_header_content;
const { popup, setPopup } = usePopup();
const shouldUseAgenticDisplay =
agenticResults &&
(searchResponse.documents || []).some(
(document) =>
searchResponse.additional_relevance &&
searchResponse.additional_relevance[document.document_id] !== undefined
);
return (
<>
<div className="flex relative pr-[8px] h-full text-default">
{popup}
{!shouldDisplayNoSources &&
showApiKeyModal &&
!shouldShowWelcomeModal && (
<ApiKeyModal
setPopup={setPopup}
hide={() => setShowApiKeyModal(false)}
/>
)}
{deletingChatSession && (
<DeleteEntityModal
entityType="search"
entityName={deletingChatSession.name}
onClose={() => setDeletingChatSession(null)}
onSubmit={async () => {
const response = await deleteChatSession(deletingChatSession.id);
if (response.ok) {
setDeletingChatSession(null);
// go back to the main page
router.push("/search");
} else {
const responseJson = await response.json();
setPopup({ message: responseJson.detail, type: "error" });
}
router.refresh();
}}
/>
)}
{currentFeedback && (
<FeedbackModal
feedbackType={currentFeedback[0]}
onClose={() => setCurrentFeedback(null)}
onSubmit={({ message, predefinedFeedback }) => {
onFeedback(
currentFeedback[1],
currentFeedback[0],
message,
predefinedFeedback
);
setCurrentFeedback(null);
}}
/>
)}
<div
ref={sidebarElementRef}
className={`
flex-none
fixed
left-0
z-30
bg-background-100
h-screen
transition-all
bg-opacity-80
duration-300
ease-in-out
${
!untoggled && (showDocSidebar || toggledSidebar)
? "opacity-100 w-[250px] translate-x-0"
: "opacity-0 w-[200px] pointer-events-none -translate-x-10"
}
`}
>
<div className="w-full relative">
<HistorySidebar
showDeleteModal={showDeleteModal}
explicitlyUntoggle={explicitlyUntoggle}
reset={() => setQuery("")}
page="search"
ref={innerSidebarElementRef}
toggleSidebar={toggleSidebar}
toggled={toggledSidebar}
existingChats={querySessions}
/>
</div>
</div>
<div className="absolute default-scrollbar h-screen overflow-y-auto overflow-x-hidden left-0 w-full top-0">
<FunctionalHeader
sidebarToggled={toggledSidebar}
reset={() => setQuery("")}
toggleSidebar={toggleSidebar}
page="search"
/>
<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-[250px]" : "w-[0px]"}
`}
/>
{
<div
className={`desktop:px-24 w-full ${
chatBannerPresent && "mt-10"
} pt-10 relative max-w-[2000px] xl:max-w-[1430px] mx-auto`}
>
<div className="absolute z-10 mobile:px-4 mobile:max-w-searchbar-max mobile:w-[90%] top-12 desktop:left-4 hidden 2xl:block mobile:left-1/2 mobile:transform mobile:-translate-x-1/2 desktop:w-52 3xl:w-64">
{!settings?.isMobile &&
(ccPairs.length > 0 || documentSets.length > 0) && (
<SourceSelector
{...filterManager}
showDocSidebar={toggledSidebar}
availableDocumentSets={finalAvailableDocumentSets}
existingSources={finalAvailableSources}
availableTags={tags}
/>
)}
</div>
<div className="absolute left-0 hidden 2xl:block w-52 3xl:w-64"></div>
<div className="max-w-searchbar-max w-[90%] mx-auto">
{settings?.isMobile && (
<div className="mt-6">
{!(agenticResults && isFetching) || disabledAgentic ? (
<SearchResultsDisplay
searchState={searchState}
disabledAgentic={disabledAgentic}
contentEnriched={contentEnriched}
comments={comments}
sweep={sweep}
agenticResults={agenticResults && !disabledAgentic}
performSweep={performSweep}
searchResponse={searchResponse}
isFetching={isFetching}
defaultOverrides={defaultOverrides}
/>
) : (
<></>
)}
</div>
)}
<div
className={`mobile:fixed mobile:left-1/2 mobile:transform mobile:-translate-x-1/2 mobile:max-w-search-bar-max mobile:w-[90%] mobile:z-100 mobile:bottom-12`}
>
<div
className={`transition-all duration-500 ease-in-out overflow-hidden
${
firstSearch
? "opacity-100 max-h-[500px]"
: "opacity-0 max-h-0"
}`}
onTransitionEnd={handleTransitionEnd}
>
<div className="mt-48 mb-8 flex justify-center items-center">
<div className="w-message-xs 2xl:w-message-sm 3xl:w-message">
<div className="flex">
<div className="text-3xl font-bold font-strong text-strong mx-auto">
Unlock Knowledge
</div>
</div>
</div>
</div>
</div>
<UnconfiguredProviderText
noSources={shouldDisplayNoSources}
showConfigureAPIKey={() => setShowApiKeyModal(true)}
/>
<FullSearchBar
disabled={!isSearchChanged()}
toggleAgentic={
disabledAgentic ? undefined : toggleAgentic
}
showingSidebar={toggledSidebar}
agentic={agentic}
query={query}
setQuery={setQuery}
onSearch={async (agentic?: boolean) => {
setDefaultOverrides(SEARCH_DEFAULT_OVERRIDES_START);
await onSearch({ agentic, offset: 0 });
}}
finalAvailableDocumentSets={finalAvailableDocumentSets}
finalAvailableSources={finalAvailableSources}
filterManager={filterManager}
documentSets={documentSets}
ccPairs={ccPairs}
tags={tags}
/>
</div>
{!firstSearch && (
<SearchAnswer
isFetching={isFetching}
dedupedQuotes={dedupedQuotes}
searchResponse={searchResponse}
setSearchAnswerExpanded={setSearchAnswerExpanded}
searchAnswerExpanded={searchAnswerExpanded}
setCurrentFeedback={setCurrentFeedback}
searchState={searchState}
/>
)}
{!settings?.isMobile && (
<div className="mt-6">
{!(agenticResults && isFetching) || disabledAgentic ? (
<SearchResultsDisplay
searchState={searchState}
disabledAgentic={disabledAgentic}
contentEnriched={contentEnriched}
comments={comments}
sweep={sweep}
agenticResults={
shouldUseAgenticDisplay && !disabledAgentic
}
performSweep={performSweep}
searchResponse={searchResponse}
isFetching={isFetching}
defaultOverrides={defaultOverrides}
/>
) : (
<></>
)}
</div>
)}
</div>
</div>
}
</div>
</div>
</div>
<FixedLogo backgroundToggled={toggledSidebar || showDocSidebar} />
</>
);
};

View File

@ -1,43 +0,0 @@
import { SearchType } from "@/lib/search/interfaces";
const defaultStyle =
"py-1 px-2 border rounded border-gray-700 cursor-pointer font-bold ";
interface Props {
selectedSearchType: SearchType;
setSelectedSearchType: (searchType: SearchType) => void;
}
export const SearchTypeSelector: React.FC<Props> = ({
selectedSearchType,
setSelectedSearchType,
}) => {
return (
<div className="flex text-xs">
<div
className={
defaultStyle +
(selectedSearchType === SearchType.SEMANTIC
? "bg-blue-500"
: "bg-gray-800 hover:bg-gray-600")
}
onClick={() => setSelectedSearchType(SearchType.SEMANTIC)}
>
AI Search
</div>
<div
className={
defaultStyle +
"ml-2 " +
(selectedSearchType === SearchType.KEYWORD
? "bg-blue-500"
: "bg-gray-800 hover:bg-gray-600")
}
onClick={() => setSelectedSearchType(SearchType.KEYWORD)}
>
Keyword Search
</div>
</div>
);
};

View File

@ -33,6 +33,7 @@ import {
getDateRangeString,
getTimeAgoString,
} from "@/lib/dateUtils";
import { Separator } from "@/components/ui/separator";
const SectionTitle = ({ children }: { children: string }) => (
<div className="font-bold text-xs mt-2 flex">{children}</div>
@ -53,6 +54,9 @@ export interface SourceSelectorProps {
availableDocumentSets: DocumentSet[];
existingSources: ValidSources[];
availableTags: Tag[];
toggleFilters?: () => void;
filtersUntoggled?: boolean;
tagsOnLeft?: boolean;
}
export function SourceSelector({
@ -68,6 +72,9 @@ export function SourceSelector({
existingSources,
availableTags,
showDocSidebar,
toggleFilters,
filtersUntoggled,
tagsOnLeft,
}: SourceSelectorProps) {
const handleSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
@ -110,138 +117,155 @@ export function SourceSelector({
showDocSidebar ? "4xl:block" : "!block"
} duration-1000 flex ease-out transition-all transform origin-top-right`}
>
<div className="mb-4 pb-2 flex border-b border-border text-emphasis">
<button
onClick={() => toggleFilters && toggleFilters()}
className="flex text-emphasis"
>
<h2 className="font-bold my-auto">Filters</h2>
<FiFilter className="my-auto ml-2" size="16" />
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer">
<SectionTitle>Time Range</SectionTitle>
<p className="text-sm text-default mt-2">
{timeRange?.from
? getDateRangeString(timeRange.from, timeRange.to)
: "Since"}
</p>
</div>
</PopoverTrigger>
<PopoverContent
className="bg-background border-border border rounded-md z-[200] p-0"
align="start"
>
<Calendar
mode="range"
selected={
timeRange
? { from: new Date(timeRange.from), to: new Date(timeRange.to) }
: undefined
}
onSelect={(daterange) => {
const initialDate = daterange?.from || new Date();
const endDate = daterange?.to || new Date();
setTimeRange({
from: initialDate,
to: endDate,
selectValue: timeRange?.selectValue || "",
});
}}
className="rounded-md "
/>
</PopoverContent>
</Popover>
{availableTags.length > 0 && (
</button>
{!filtersUntoggled && (
<>
<div className="mt-4 mb-2">
<SectionTitle>Tags</SectionTitle>
</div>
<TagFilter
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
</>
)}
{existingSources.length > 0 && (
<div className="mt-4">
<div className="flex w-full gap-x-2 items-center">
<div className="font-bold text-xs mt-2 flex items-center gap-x-2">
<p>Sources</p>
<input
type="checkbox"
checked={allSourcesSelected}
onChange={toggleAllSources}
className="my-auto form-checkbox h-3 w-3 text-primary border-background-900 rounded"
<Separator />
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer">
<SectionTitle>Time Range</SectionTitle>
<p className="text-sm text-default mt-2">
{getDateRangeString(timeRange?.from!, timeRange?.to!) ||
"Select a time range"}
</p>
</div>
</PopoverTrigger>
<PopoverContent
className="bg-background-search-filter border-border border rounded-md z-[200] p-0"
align="start"
>
<Calendar
mode="range"
selected={
timeRange
? {
from: new Date(timeRange.from),
to: new Date(timeRange.to),
}
: undefined
}
onSelect={(daterange) => {
const initialDate = daterange?.from || new Date();
const endDate = daterange?.to || new Date();
setTimeRange({
from: initialDate,
to: endDate,
selectValue: timeRange?.selectValue || "",
});
}}
className="rounded-md "
/>
</div>
</div>
<div className="px-1">
{listSourceMetadata()
.filter((source) => existingSources.includes(source.internalName))
.map((source) => (
<div
key={source.internalName}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 my-1.5 rounded-lg px-2 select-none " +
(selectedSources
.map((source) => source.internalName)
.includes(source.internalName)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleSelect(source)}
>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2 text-sm text-default">
{source.displayName}
</span>
</div>
))}
</div>
</div>
)}
</PopoverContent>
</Popover>
{availableDocumentSets.length > 0 && (
<>
<div className="mt-4">
<SectionTitle>Knowledge Sets</SectionTitle>
</div>
<div className="px-1">
{availableDocumentSets.map((documentSet) => (
<div key={documentSet.name} className="my-1.5 flex">
<div
key={documentSet.name}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 rounded-lg px-2 " +
(selectedDocumentSets.includes(documentSet.name)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleDocumentSetSelect(documentSet.name)}
>
<HoverPopup
mainContent={
<div className="flex my-auto mr-2">
<InfoIcon className={defaultTailwindCSS} />
</div>
}
popupContent={
<div className="text-sm w-64">
<div className="flex font-medium">Description</div>
<div className="mt-1">{documentSet.description}</div>
</div>
}
classNameModifications="-ml-2"
{availableTags.length > 0 && (
<>
<div className="mt-4 mb-2">
<SectionTitle>Tags</SectionTitle>
</div>
<TagFilter
showTagsOnLeft={true}
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
</>
)}
{existingSources.length > 0 && (
<div className="mt-4">
<div className="flex w-full gap-x-2 items-center">
<div className="font-bold text-xs mt-2 flex items-center gap-x-2">
<p>Sources</p>
<input
type="checkbox"
checked={allSourcesSelected}
onChange={toggleAllSources}
className="my-auto form-checkbox h-3 w-3 text-primary border-background-900 rounded"
/>
<span className="text-sm">{documentSet.name}</span>
</div>
</div>
))}
</div>
<div className="px-1">
{listSourceMetadata()
.filter((source) =>
existingSources.includes(source.internalName)
)
.map((source) => (
<div
key={source.internalName}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 my-1.5 rounded-lg px-2 select-none " +
(selectedSources
.map((source) => source.internalName)
.includes(source.internalName)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleSelect(source)}
>
<SourceIcon
sourceType={source.internalName}
iconSize={16}
/>
<span className="ml-2 text-sm text-default">
{source.displayName}
</span>
</div>
))}
</div>
</div>
)}
{availableDocumentSets.length > 0 && (
<>
<div className="mt-4">
<SectionTitle>Knowledge Sets</SectionTitle>
</div>
<div className="px-1">
{availableDocumentSets.map((documentSet) => (
<div key={documentSet.name} className="my-1.5 flex">
<div
key={documentSet.name}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 rounded-lg px-2 " +
(selectedDocumentSets.includes(documentSet.name)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleDocumentSetSelect(documentSet.name)}
>
<HoverPopup
mainContent={
<div className="flex my-auto mr-2">
<InfoIcon className={defaultTailwindCSS} />
</div>
}
popupContent={
<div className="text-sm w-64">
<div className="flex font-medium">Description</div>
<div className="mt-1">
{documentSet.description}
</div>
</div>
}
classNameModifications="-ml-2"
/>
<span className="text-sm">{documentSet.name}</span>
</div>
</div>
))}
</div>
</>
)}
</>
)}
</div>

View File

@ -6,13 +6,17 @@ import debounce from "lodash/debounce";
import { getValidTags } from "@/lib/tags/tagUtils";
export function TagFilter({
modal,
tags,
selectedTags,
setSelectedTags,
showTagsOnLeft = false,
}: {
modal?: boolean;
tags: Tag[];
selectedTags: Tag[];
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
showTagsOnLeft?: boolean;
}) {
const [filterValue, setFilterValue] = useState("");
const [tagOptionsAreVisible, setTagOptionsAreVisible] = useState(false);
@ -72,10 +76,12 @@ export function TagFilter({
};
return (
<div className="relative">
<div className="relative w-full ">
<input
ref={inputRef}
className="w-full border border-border py-0.5 px-2 rounded text-sm h-8"
className={` border border-border py-0.5 px-2 rounded text-sm h-8 ${
modal ? "w-[80vw]" : "w-full"
}`}
placeholder="Find a tag"
value={filterValue}
onChange={handleFilterChange}
@ -106,7 +112,13 @@ export function TagFilter({
</div>
)}
{tagOptionsAreVisible && (
<div className="absolute top-0 right-0 transform translate-x-[105%] z-40">
<div
className={` absolute z-[100] ${
showTagsOnLeft
? "left-0 top-0 translate-y-[2rem]"
: "right-0 translate-x-[105%] top-0"
} z-40`}
>
<div
ref={popupRef}
className="p-2 border border-border rounded shadow-lg w-72 bg-background"

View File

@ -1,55 +1,71 @@
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
import { ReactNode } from "react";
import { CompactDocumentCard } from "../DocumentDisplay";
import { LoadedDanswerDocument } from "@/lib/search/interfaces";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// NOTE: This is the preivous version of the citations which works just fine
export function Citation({
children,
link,
document,
index,
icon,
url,
}: {
link?: string;
children?: JSX.Element | string | null | ReactNode;
index?: number;
document: LoadedDanswerDocument;
icon?: React.ReactNode;
url?: string;
}) {
const innerText = children
? children?.toString().split("[")[1].split("]")[0]
: index;
if (link != "") {
if (link) {
return (
<CustomTooltip
citation
content={<div className="inline-block p-0 m-0 truncate">{link}</div>}
>
<a
onMouseDown={() => (link ? window.open(link, "_blank") : undefined)}
className="cursor-pointer inline ml-1 align-middle"
>
<span className="group relative -top-1 text-sm text-gray-500 dark:text-gray-400 selection:bg-indigo-300 selection:text-black dark:selection:bg-indigo-900 dark:selection:text-white">
<span
className="inline-flex bg-background-200 group-hover:bg-background-300 items-center justify-center h-3.5 min-w-3.5 px-1 text-center text-xs rounded-full border-1 border-gray-400 ring-1 ring-gray-400 divide-gray-300 dark:divide-gray-700 dark:ring-gray-700 dark:border-gray-700 transition duration-150"
data-number="3"
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div
onMouseDown={() => window.open(link, "_blank")}
className="inline-flex items-center ml-1 cursor-pointer transition-all duration-200 ease-in-out"
>
{innerText}
</span>
</span>
</a>
</CustomTooltip>
<span className="relative min-w-[1.4rem] text-center no-underline -top-0.5 px-1.5 py-0.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-full border border-gray-300 hover:bg-gray-200 hover:text-gray-900 shadow-sm no-underline">
{innerText}
</span>
</div>
</TooltipTrigger>
<TooltipContent width="mb-2 max-w-lg" className="bg-background">
<CompactDocumentCard url={url} icon={icon} document={document} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
} else {
return (
<CustomTooltip content={<div>This doc doesn&apos;t have a link!</div>}>
<div className="inline-block cursor-help leading-none inline ml-1 align-middle">
<span className="group relative -top-1 text-gray-500 dark:text-gray-400 selection:bg-indigo-300 selection:text-black dark:selection:bg-indigo-900 dark:selection:text-white">
<span
className="inline-flex bg-background-200 group-hover:bg-background-300 items-center justify-center h-3.5 min-w-3.5 flex-none px-1 text-center text-xs rounded-full border-1 border-gray-400 ring-1 ring-gray-400 divide-gray-300 dark:divide-gray-700 dark:ring-gray-700 dark:border-gray-700 transition duration-150"
data-number="3"
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div
onMouseDown={() => window.open(link, "_blank")}
className="inline-flex items-center ml-1 cursor-pointer transition-all duration-200 ease-in-out"
>
{innerText}
</span>
</span>
</div>
</CustomTooltip>
<span className="relative min-w-[1.4rem] pchatno-underline -top-0.5 px-1.5 py-0.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-full border border-gray-300 hover:bg-gray-200 hover:text-gray-900 shadow-sm no-underline">
{innerText}
</span>
</div>
</TooltipTrigger>
<TooltipContent width="mb-2 max-w-lg" backgroundColor="bg-background">
<CompactDocumentCard url={url} icon={icon} document={document} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
}

View File

@ -6,8 +6,6 @@ import {
TriangleAlertIcon,
} from "@/components/icons/icons";
import { useState } from "react";
import { Grid } from "react-loader-spinner";
import { searchState } from "../SearchSection";
export type StatusOptions = "in-progress" | "failed" | "warning" | "success";

View File

@ -43,11 +43,9 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
if (!results[0].ok) {
if (results[0].status === 403 || results[0].status === 401) {
settings = {
auto_scroll: true,
product_gating: GatingType.NONE,
gpu_enabled: false,
chat_page_enabled: true,
search_page_enabled: true,
default_page: "search",
maximum_chat_retention_days: null,
notifications: [],
needs_reindexing: false,

View File

@ -131,7 +131,7 @@ export const CustomTooltip = ({
transform -translate-x-1/2 text-sm
${
light
? "text-gray-800 bg-background-200"
? "text-text-800 bg-background-200"
: "text-white bg-background-800"
}
rounded-lg shadow-lg`}

View File

@ -50,12 +50,13 @@ function Badge({
...props
}: BadgeProps & {
icon?: React.ElementType;
size?: "sm" | "md";
size?: "sm" | "md" | "xs";
circle?: boolean;
}) {
const sizeClasses = {
sm: "px-2.5 py-0.5 text-xs",
md: "px-3 py-1 text-sm",
xs: "px-1.5 py-0.25 text-[.5rem]", // Made xs smaller
};
return (
@ -64,10 +65,20 @@ function Badge({
{...props}
>
{Icon && (
<Icon className={cn("mr-1", size === "sm" ? "h-3 w-3" : "h-4 w-4")} />
<Icon
className={cn(
"mr-1",
size === "sm" ? "h-3 w-3" : size === "xs" ? "h-2 w-2" : "h-4 w-4"
)}
/>
)}
{circle && (
<div className="h-2.5 w-2.5 mr-2 rounded-full bg-current opacity-80" />
<div
className={cn(
"mr-2 rounded-full bg-current opacity-80",
size === "xs" ? "h-2 w-2" : "h-2.5 w-2.5"
)}
/>
)}
{props.children}
</div>

View File

@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-neutral-200 border-neutral-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-neutral-800 dark:border-neutral-50 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=checked]:text-neutral-900",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,118 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-neutral-100 dark:bg-neutral-800" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=unchecked]:bg-neutral-800",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 dark:bg-neutral-950"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -13,17 +13,19 @@ const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
maxWidth?: string;
width?: string;
backgroundColor?: string;
}
>(({ className, sideOffset = 4, maxWidth, backgroundColor, ...props }, ref) => (
>(({ className, sideOffset = 4, width, backgroundColor, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
`z-50 overflow-hidden rounded-md border border-neutral-200 text-white ${
backgroundColor || "bg-background-900"
} px-2 py-1.5 text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50 max-w-sm`,
}
${width || "max-w-sm"}
px-2 py-1.5 text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50 `,
className
)}
{...props}

View File

@ -12,6 +12,7 @@ interface UserContextType {
isCurator: boolean;
refreshUser: () => Promise<void>;
isCloudSuperuser: boolean;
updateUserAutoScroll: (autoScroll: boolean | null) => Promise<void>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
@ -55,6 +56,36 @@ export function UserProvider({
setIsLoadingUser(false);
}
};
const updateUserAutoScroll = async (autoScroll: boolean | null) => {
try {
const response = await fetch("/api/auto-scroll", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ auto_scroll: autoScroll }),
});
setUpToDateUser((prevUser) => {
if (prevUser) {
return {
...prevUser,
preferences: {
...prevUser.preferences,
auto_scroll: autoScroll,
},
};
}
return prevUser;
});
if (!response.ok) {
throw new Error("Failed to update auto-scroll setting");
}
} catch (error) {
console.error("Error updating auto-scroll setting:", error);
throw error;
}
};
const refreshUser = async () => {
await fetchUser();
@ -66,6 +97,7 @@ export function UserProvider({
user: upToDateUser,
isLoadingUser,
refreshUser,
updateUserAutoScroll,
isAdmin: upToDateUser?.role === UserRole.ADMIN,
// Curator status applies for either global or basic curator
isCurator:

View File

@ -19,6 +19,10 @@ export interface AnswerPiecePacket {
answer_piece: string;
}
export interface FinalContextDocs {
final_context_docs: DanswerDocument[];
}
export enum StreamStopReason {
CONTEXT_LENGTH = "CONTEXT_LENGTH",
CANCELLED = "CANCELLED",
@ -62,6 +66,9 @@ export interface DanswerDocument {
is_internet: boolean;
validationState?: null | "good" | "bad";
}
export interface LoadedDanswerDocument extends DanswerDocument {
icon: React.FC<{ size?: number; className?: string }>;
}
export interface SearchDanswerDocument extends DanswerDocument {
is_relevant: boolean;

View File

@ -7,6 +7,7 @@ import {
DanswerDocument,
DocumentInfoPacket,
ErrorMessagePacket,
FinalContextDocs,
Quote,
QuotesInfoPacket,
RelevanceChunk,
@ -91,6 +92,7 @@ export const searchRequestStreamed = async ({
| DocumentInfoPacket
| LLMRelevanceFilterPacket
| BackendMessage
| FinalContextDocs
| RelevanceChunk
>(decoder.decode(value, { stream: true }), previousPartialChunk);
if (!completedChunks.length && !partialChunk) {

View File

@ -58,7 +58,7 @@ type SourceMap = {
[K in ValidSources]: PartialSourceMetadata;
};
const SOURCE_METADATA_MAP: SourceMap = {
export const SOURCE_METADATA_MAP: SourceMap = {
web: {
icon: GlobeIcon,
displayName: "Web",

View File

@ -9,6 +9,7 @@ interface UserPreferences {
hidden_assistants: number[];
default_model: string | null;
recent_assistants: number[];
auto_scroll: boolean | null;
}
export enum UserStatus {

View File

@ -336,6 +336,7 @@ module.exports = {
"tremor-full": "9999px",
},
fontSize: {
"2xs": "0.625rem",
"code-sm": "small",
"tremor-label": ["0.75rem"],
"tremor-default": ["0.875rem", { lineHeight: "1.25rem" }],