Feature/assistants (#1581)

* include alternate assisstant

- migrate models
- migrate db

* functional alternate assistant selection

* refactor chat components for persona API

* functional assistants api

* add full functionality- assistants

* add functional assistants dropdown handler

* refactor assistants for full compatability

- hooks
- track the live assistant for edge cases
- UI updates

* add assistant UI features

- Autotab
- Arrow selection
- Icons
- Proper @ detection
- Info Popup

prune unnecessary comments

* functional search toggling for assistants

* add functional cross-page assistants

rebase with main

* add proper interactivity for edge cases

- click outside of input / text box
- "force search" assistant consistency

* refactor alt assistant consistency

* update alembic versions

* rebased

* undo formatting changes

* additional formatting

* current processing

* merge fixes

* formatting

* colors

* 2 -> 1

* 1 -> 2

---------

Co-authored-by: “Pablo <“pablo@danswer.ai”>
This commit is contained in:
pablodanswer 2024-06-28 17:18:39 -07:00 committed by GitHub
parent 60dd77393d
commit ed550986a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 469 additions and 97 deletions

View File

@ -49,4 +49,4 @@ PYTHONUNBUFFERED=1
# Enable the full set of Danswer Enterprise Edition features
# NOTE: DO NOT ENABLE THIS UNLESS YOU HAVE A PAID ENTERPRISE LICENSE (or if you are using this for local testing/development)
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=False
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=False

View File

@ -0,0 +1,38 @@
"""add alternate assistant to chat message
Revision ID: 3a7802814195
Revises: 23957775e5f5
Create Date: 2024-06-05 11:18:49.966333
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "3a7802814195"
down_revision = "23957775e5f5"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"chat_message", sa.Column("alternate_assistant_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(
"fk_chat_message_persona",
"chat_message",
"persona",
["alternate_assistant_id"],
["id"],
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("fk_chat_message_persona", "chat_message", type_="foreignkey")
op.drop_column("chat_message", "alternate_assistant_id")

View File

@ -34,6 +34,7 @@ from danswer.db.llm import fetch_existing_llm_providers
from danswer.db.models import SearchDoc as DbSearchDoc
from danswer.db.models import ToolCall
from danswer.db.models import User
from danswer.db.persona import get_persona_by_id
from danswer.document_index.factory import get_default_document_index
from danswer.file_store.models import ChatFileType
from danswer.file_store.models import FileDescriptor
@ -223,7 +224,15 @@ def stream_chat_message_objects(
parent_id = new_msg_req.parent_message_id
reference_doc_ids = new_msg_req.search_doc_ids
retrieval_options = new_msg_req.retrieval_options
persona = chat_session.persona
alternate_assistant_id = new_msg_req.alternate_assistant_id
# use alternate persona if alternative assistant id is passed in
if alternate_assistant_id is not None:
persona = get_persona_by_id(
alternate_assistant_id, user=user, db_session=db_session
)
else:
persona = chat_session.persona
prompt_id = new_msg_req.prompt_id
if prompt_id is None and persona.prompts:
@ -380,6 +389,7 @@ def stream_chat_message_objects(
# rephrased_query=,
# token_count=,
message_type=MessageType.ASSISTANT,
alternate_assistant_id=new_msg_req.alternate_assistant_id,
# error=,
# reference_docs=,
db_session=db_session,
@ -389,11 +399,15 @@ def stream_chat_message_objects(
if not final_msg.prompt:
raise RuntimeError("No Prompt found")
prompt_config = PromptConfig.from_model(
final_msg.prompt,
prompt_override=(
new_msg_req.prompt_override or chat_session.prompt_override
),
prompt_config = (
PromptConfig.from_model(
final_msg.prompt,
prompt_override=(
new_msg_req.prompt_override or chat_session.prompt_override
),
)
if not persona
else PromptConfig.from_model(persona.prompts[0])
)
# find out what tools to use

View File

@ -316,6 +316,7 @@ def create_new_chat_message(
rephrased_query: str | None = None,
error: str | None = None,
reference_docs: list[DBSearchDoc] | None = None,
alternate_assistant_id: int | None = None,
# Maps the citation number [n] to the DB SearchDoc
citations: dict[int, int] | None = None,
tool_calls: list[ToolCall] | None = None,
@ -334,6 +335,7 @@ def create_new_chat_message(
files=files,
tool_calls=tool_calls if tool_calls else [],
error=error,
alternate_assistant_id=alternate_assistant_id,
)
# SQL Alchemy will propagate this to update the reference_docs' foreign keys
@ -497,14 +499,14 @@ def translate_db_search_doc_to_server_search_doc(
hidden=db_search_doc.hidden,
metadata=db_search_doc.doc_metadata if not remove_doc_content else {},
score=db_search_doc.score,
match_highlights=db_search_doc.match_highlights
if not remove_doc_content
else [],
match_highlights=(
db_search_doc.match_highlights if not remove_doc_content else []
),
updated_at=db_search_doc.updated_at if not remove_doc_content else None,
primary_owners=db_search_doc.primary_owners if not remove_doc_content else [],
secondary_owners=db_search_doc.secondary_owners
if not remove_doc_content
else [],
secondary_owners=(
db_search_doc.secondary_owners if not remove_doc_content else []
),
)
@ -545,6 +547,7 @@ def translate_db_message_to_chat_message_detail(
)
for tool_call in chat_message.tool_calls
],
alternate_assistant_id=chat_message.alternate_assistant_id,
)
return chat_msg_detail

View File

@ -708,6 +708,11 @@ class ChatMessage(Base):
id: Mapped[int] = mapped_column(primary_key=True)
chat_session_id: Mapped[int] = mapped_column(ForeignKey("chat_session.id"))
alternate_assistant_id = mapped_column(
Integer, ForeignKey("persona.id"), nullable=True
)
parent_message: Mapped[int | None] = mapped_column(Integer, nullable=True)
latest_child_message: Mapped[int | None] = mapped_column(Integer, nullable=True)
message: Mapped[str] = mapped_column(Text)
@ -736,10 +741,12 @@ class ChatMessage(Base):
chat_session: Mapped[ChatSession] = relationship("ChatSession")
prompt: Mapped[Optional["Prompt"]] = relationship("Prompt")
chat_message_feedbacks: Mapped[list["ChatMessageFeedback"]] = relationship(
"ChatMessageFeedback",
back_populates="chat_message",
)
document_feedbacks: Mapped[list["DocumentRetrievalFeedback"]] = relationship(
"DocumentRetrievalFeedback",
back_populates="chat_message",

View File

@ -107,6 +107,9 @@ class CreateChatMessageRequest(ChunkContext):
llm_override: LLMOverride | None = None
prompt_override: PromptOverride | None = None
# allow user to specify an alternate assistnat
alternate_assistant_id: int | None = None
# used for seeded chats to kick off the generation of an AI answer
use_existing_user_message: bool = False
@ -181,6 +184,7 @@ class ChatMessageDetail(BaseModel):
context_docs: RetrievalDocs | None
message_type: MessageType
time_sent: datetime
alternate_assistant_id: str | None
# Dict mapping citation number to db_doc_id
citations: dict[int, int] | None
files: list[FileDescriptor]

View File

@ -7,7 +7,6 @@ import { FiBookmark, FiCpu, FiInfo, FiX, FiZoomIn } from "react-icons/fi";
import { HoverPopup } from "@/components/HoverPopup";
import { Modal } from "@/components/Modal";
import { useState } from "react";
import { FaCaretDown, FaCaretRight } from "react-icons/fa";
import { Logo } from "@/components/Logo";
const MAX_PERSONAS_TO_DISPLAY = 4;
@ -29,11 +28,9 @@ function HelperItemDisplay({
export function ChatIntro({
availableSources,
availablePersonas,
selectedPersona,
}: {
availableSources: ValidSources[];
availablePersonas: Persona[];
selectedPersona: Persona;
}) {
const availableSourceMetadata = getSourceMetadataForSources(availableSources);

View File

@ -22,6 +22,7 @@ import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import {
buildChatUrl,
buildLatestMessageChain,
checkAnyAssistantHasSearch,
createChatSession,
getCitedDocumentsFromMessage,
getHumanAndAIMessageFromMessageNumber,
@ -62,7 +63,6 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
import Dropzone from "react-dropzone";
import {
checkLLMSupportsImageInput,
destructureValue,
getFinalLLM,
structureValue,
} from "@/lib/llm/utils";
@ -78,9 +78,7 @@ import { TbLayoutSidebarRightExpand } from "react-icons/tb";
import { SIDEBAR_WIDTH_CONST } from "@/lib/constants";
import ResizableSection from "@/components/resizable/ResizableSection";
import { Button } from "@tremor/react";
const MAX_INPUT_HEIGHT = 200;
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
const SYSTEM_MESSAGE_ID = -3;
@ -108,6 +106,12 @@ export function ChatPage({
const filteredAssistants = orderAssistantsForUser(availablePersonas, user);
const [selectedAssistant, setSelectedAssistant] = useState<Persona | null>(
null
);
const [alternativeGeneratingAssistant, setAlternativeGeneratingAssistant] =
useState<Persona | null>(null);
const router = useRouter();
const searchParams = useSearchParams();
const existingChatIdRaw = searchParams.get("chatId");
@ -213,6 +217,7 @@ export function ChatPage({
const response = await fetch(
`/api/chat/get-chat-session/${existingChatSessionId}`
);
const chatSession = (await response.json()) as BackendChatSession;
setSelectedPersona(
@ -350,6 +355,7 @@ export function ChatPage({
setCompleteMessageMap(newCompleteMessageMap);
return newCompleteMessageMap;
};
const messageHistory = buildLatestMessageChain(completeMessageMap);
const [isStreaming, setIsStreaming] = useState(false);
@ -405,6 +411,7 @@ export function ChatPage({
// just choose a conservative default, this will be updated in the
// background on initial load / on persona change
const [maxTokens, setMaxTokens] = useState<number>(4096);
// fetch # of allowed document tokens for the selected Persona
useEffect(() => {
async function fetchMaxTokens() {
@ -619,13 +626,17 @@ export function ChatPage({
queryOverride,
forceSearch,
isSeededChat,
alternativeAssistant = null,
}: {
messageIdToResend?: number;
messageOverride?: string;
queryOverride?: string;
forceSearch?: boolean;
isSeededChat?: boolean;
alternativeAssistant?: Persona | null;
} = {}) => {
setAlternativeGeneratingAssistant(alternativeAssistant);
clientScrollToBottom();
let currChatSessionId: number;
let isNewSession = chatSessionId === null;
@ -645,6 +656,7 @@ export function ChatPage({
const messageToResend = messageHistory.find(
(message) => message.messageId === messageIdToResend
);
const messageToResendParent =
messageToResend?.parentMessageId !== null &&
messageToResend?.parentMessageId !== undefined
@ -703,12 +715,19 @@ export function ChatPage({
const frozenCompleteMessageMap = upsertToCompleteMessageMap({
messages: messageUpdates,
});
// on initial message send, we insert a dummy system message
// set this as the parent here if no parent is set
if (!parentMessage && frozenCompleteMessageMap.size === 2) {
parentMessage = frozenCompleteMessageMap.get(SYSTEM_MESSAGE_ID) || null;
}
const currentAssistantId = alternativeAssistant
? alternativeAssistant.id
: selectedAssistant?.id;
resetInputBar();
setIsStreaming(true);
let answer = "";
let query: string | null = null;
@ -721,6 +740,7 @@ export function ChatPage({
let error: string | null = null;
let finalMessage: BackendMessage | null = null;
let toolCalls: ToolCallMetadata[] = [];
try {
const lastSuccessfulMessageId =
getLastSuccessfulMessageId(currMessageHistory);
@ -728,6 +748,7 @@ export function ChatPage({
const stack = new CurrentMessageFIFO();
updateCurrentMessageFIFO(stack, {
message: currMessage,
alternateAssistantId: currentAssistantId,
fileDescriptors: currentMessageFiles,
parentMessageId: lastSuccessfulMessageId,
chatSessionId: currChatSessionId,
@ -846,6 +867,7 @@ export function ChatPage({
files: finalMessage?.files || aiMessageImages || [],
toolCalls: finalMessage?.tool_calls || toolCalls,
parentMessageId: newUserMessageId,
alternateAssistantID: selectedAssistant?.id,
},
]);
}
@ -905,6 +927,7 @@ export function ChatPage({
) {
setSelectedMessageForDocDisplay(finalMessage.message_id);
}
setAlternativeGeneratingAssistant(null);
};
const onFeedback = async (
@ -1021,10 +1044,37 @@ export function ChatPage({
setShowDocSidebar((showDocSidebar) => !showDocSidebar); // Toggle the state which will in turn toggle the class
};
const retrievalDisabled = !personaIncludesRetrieval(livePersona);
useEffect(() => {
const includes = checkAnyAssistantHasSearch(
messageHistory,
availablePersonas,
livePersona
);
setRetrievalEnabled(includes);
}, [messageHistory, availablePersonas, livePersona]);
const [retrievalEnabled, setRetrievalEnabled] = useState(() => {
return checkAnyAssistantHasSearch(
messageHistory,
availablePersonas,
livePersona
);
});
const [editingRetrievalEnabled, setEditingRetrievalEnabled] = useState(false);
const sidebarElementRef = useRef<HTMLDivElement>(null);
const innerSidebarElementRef = useRef<HTMLDivElement>(null);
const currentPersona = selectedAssistant || livePersona;
const updateSelectedAssistant = (newAssistant: Persona | null) => {
setSelectedAssistant(newAssistant);
if (newAssistant) {
setEditingRetrievalEnabled(personaIncludesRetrieval(newAssistant));
} else {
setEditingRetrievalEnabled(false);
}
};
return (
<>
<HealthCheckBanner />
@ -1094,7 +1144,7 @@ export function ChatPage({
<>
<div
className={`w-full sm:relative h-screen ${
retrievalDisabled ? "pb-[111px]" : "pb-[140px]"
!retrievalEnabled ? "pb-[111px]" : "pb-[140px]"
}
flex-auto transition-margin duration-300
overflow-x-auto
@ -1102,6 +1152,7 @@ export function ChatPage({
{...getRootProps()}
>
{/* <input {...getInputProps()} /> */}
<div
className={`w-full h-full flex flex-col overflow-y-auto overflow-x-hidden relative`}
ref={scrollableDivRef}
@ -1140,7 +1191,7 @@ export function ChatPage({
<div className="ml-4 flex my-auto">
<UserDropdown user={user} />
{!retrievalDisabled && !showDocSidebar && (
{retrievalEnabled && !showDocSidebar && (
<button
className="ml-4 mt-auto"
onClick={() => toggleSidebar()}
@ -1159,7 +1210,6 @@ export function ChatPage({
!isStreaming && (
<ChatIntro
availableSources={finalAvailableSources}
availablePersonas={filteredAssistants}
selectedPersona={livePersona}
/>
)}
@ -1231,6 +1281,15 @@ export function ChatPage({
i === messageHistory.length - 1);
const previousMessage =
i !== 0 ? messageHistory[i - 1] : null;
const currentAlternativeAssistant =
message.alternateAssistantID != null
? availablePersonas.find(
(persona) =>
persona.id == message.alternateAssistantID
)
: null;
return (
<div
key={`${i}-${existingChatSessionId}`}
@ -1241,6 +1300,10 @@ export function ChatPage({
}
>
<AIMessage
currentPersona={livePersona}
alternativeAssistant={
currentAlternativeAssistant
}
messageId={message.messageId}
content={message.message}
files={message.files}
@ -1297,6 +1360,8 @@ export function ChatPage({
messageIdToResend:
previousMessage.messageId,
queryOverride: newQuery,
alternativeAssistant:
currentAlternativeAssistant,
});
}
: undefined
@ -1326,6 +1391,8 @@ export function ChatPage({
messageIdToResend:
previousMessage.messageId,
forceSearch: true,
alternativeAssistant:
currentAlternativeAssistant,
});
} else {
setPopup({
@ -1335,7 +1402,13 @@ export function ChatPage({
});
}
}}
retrievalDisabled={retrievalDisabled}
retrievalDisabled={
currentAlternativeAssistant
? !personaIncludesRetrieval(
currentAlternativeAssistant!
)
: !retrievalEnabled
}
/>
</div>
);
@ -1343,6 +1416,7 @@ export function ChatPage({
return (
<div key={`${i}-${existingChatSessionId}`}>
<AIMessage
currentPersona={livePersona}
messageId={message.messageId}
personaName={livePersona.name}
content={
@ -1355,7 +1429,6 @@ export function ChatPage({
);
}
})}
{isStreaming &&
messageHistory.length > 0 &&
messageHistory[messageHistory.length - 1].type ===
@ -1364,6 +1437,11 @@ export function ChatPage({
key={`${messageHistory.length}-${existingChatSessionId}`}
>
<AIMessage
currentPersona={livePersona}
alternativeAssistant={
alternativeGeneratingAssistant ??
selectedAssistant
}
messageId={null}
personaName={livePersona.name}
content={
@ -1388,33 +1466,30 @@ export function ChatPage({
<div ref={endPaddingRef} className=" h-[95px]" />
<div ref={endDivRef}></div>
{livePersona &&
livePersona.starter_messages &&
livePersona.starter_messages.length > 0 &&
{currentPersona &&
currentPersona.starter_messages &&
currentPersona.starter_messages.length > 0 &&
selectedPersona &&
messageHistory.length === 0 &&
!isFetchingChatMessages && (
<div
className={`
mx-auto
px-4
w-searchbar-xs
2xl:w-searchbar-sm
3xl:w-searchbar
grid
gap-4
grid-cols-1
grid-rows-1
mt-4
md:grid-cols-2
mb-6`}
mx-auto
px-4
w-searchbar-xs
2xl:w-searchbar-sm
3xl:w-searchbar
grid
gap-4
grid-cols-1
grid-rows-1
mt-4
md:grid-cols-2
mb-6`}
>
{livePersona.starter_messages.map(
{currentPersona.starter_messages.map(
(starterMessage, i) => (
<div
key={`${i}-${existingChatSessionId}`}
className="w-full"
>
<div key={i} className="w-full">
<StarterMessage
starterMessage={starterMessage}
onClick={() =>
@ -1433,28 +1508,24 @@ export function ChatPage({
</div>
</div>
<div
ref={inputRef}
className="absolute bottom-0 z-10 w-full"
>
<div className="w-full relative pb-4">
{aboveHorizon && (
<div className="pointer-events-none w-full bg-transparent flex sticky justify-center">
<button
onClick={() => clientScrollToBottom(true)}
className="p-1 pointer-events-auto rounded-2xl bg-background-strong border border-border mb-2 mx-auto "
>
<FiArrowDown size={18} />
</button>
</div>
)}
<div className="absolute bottom-0 z-10 w-full">
<div className="w-full pb-4">
<ChatInputBar
onSetSelectedAssistant={(
alternativeAssistant: Persona | null
) => {
updateSelectedAssistant(alternativeAssistant);
}}
alternativeAssistant={selectedAssistant}
personas={filteredAssistants}
message={message}
setMessage={setMessage}
onSubmit={onSubmit}
isStreaming={isStreaming}
setIsCancelled={setIsCancelled}
retrievalDisabled={retrievalDisabled}
retrievalDisabled={
!personaIncludesRetrieval(currentPersona)
}
filterManager={filterManager}
llmOverrideManager={llmOverrideManager}
selectedAssistant={livePersona}
@ -1468,7 +1539,7 @@ export function ChatPage({
</div>
</div>
{!retrievalDisabled ? (
{retrievalEnabled || editingRetrievalEnabled ? (
<div
ref={sidebarElementRef}
className={`relative flex-none overflow-y-hidden sidebar bg-background-weak h-screen`}

View File

@ -14,8 +14,8 @@ export function StarterMessage({
}
onClick={onClick}
>
<p className="font-medium text-neutral-700">{starterMessage.name}</p>
<p className="text-neutral-500 text-sm">{starterMessage.description}</p>
<p className="font-medium text-emphasis">{starterMessage.name}</p>
<p className="text-subtle text-sm">{starterMessage.description}</p>
</div>
);
}

View File

@ -1,18 +1,36 @@
import React, { EventHandler, useEffect, useRef } from "react";
import { FiSend, FiFilter, FiPlusCircle, FiCpu } from "react-icons/fi";
import React, {
Dispatch,
SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import {
FiSend,
FiFilter,
FiPlusCircle,
FiCpu,
FiX,
FiPlus,
FiInfo,
} from "react-icons/fi";
import ChatInputOption from "./ChatInputOption";
import { FaBrain } from "react-icons/fa";
import { Persona } from "@/app/admin/assistants/interfaces";
import { FilterManager, LlmOverride, 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";
import { FileDescriptor } from "../interfaces";
import { InputBarPreview } from "../files/InputBarPreview";
import { RobotIcon } from "@/components/icons/icons";
import { Hoverable } from "@/components/Hoverable";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { Tooltip } from "@/components/tooltip/Tooltip";
const MAX_INPUT_HEIGHT = 200;
export function ChatInputBar({
personas,
message,
setMessage,
onSubmit,
@ -21,13 +39,17 @@ export function ChatInputBar({
retrievalDisabled,
filterManager,
llmOverrideManager,
onSetSelectedAssistant,
selectedAssistant,
files,
setFiles,
handleFileUpload,
setConfigModalActiveTab,
textAreaRef,
alternativeAssistant,
}: {
onSetSelectedAssistant: (alternativeAssistant: Persona | null) => void;
personas: Persona[];
message: string;
setMessage: (message: string) => void;
onSubmit: () => void;
@ -37,6 +59,7 @@ export function ChatInputBar({
filterManager: FilterManager;
llmOverrideManager: LlmOverrideManager;
selectedAssistant: Persona;
alternativeAssistant: Persona | null;
files: FileDescriptor[];
setFiles: (files: FileDescriptor[]) => void;
handleFileUpload: (files: File[]) => void;
@ -75,6 +98,100 @@ export function ChatInputBar({
const { llmProviders } = useChatContext();
const [_, llmName] = getFinalLLM(llmProviders, selectedAssistant, null);
const suggestionsRef = useRef<HTMLDivElement | null>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const interactionsRef = useRef<HTMLDivElement | null>(null);
const hideSuggestions = () => {
setShowSuggestions(false);
setAssistantIconIndex(0);
};
// Update selected persona
const updateCurrentPersona = (persona: Persona) => {
onSetSelectedAssistant(persona.id == selectedAssistant.id ? null : persona);
hideSuggestions();
setMessage("");
};
// Click out of assistant suggestions
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
suggestionsRef.current &&
!suggestionsRef.current.contains(event.target as Node) &&
(!interactionsRef.current ||
!interactionsRef.current.contains(event.target as Node))
) {
hideSuggestions();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// Complete user input handling
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = event.target.value;
setMessage(text);
if (!text.startsWith("@")) {
hideSuggestions();
return;
}
// If looking for an assistant...fup
const match = text.match(/(?:\s|^)@(\w*)$/);
if (match) {
setShowSuggestions(true);
} else {
hideSuggestions();
}
};
const filteredPersonas = personas.filter((persona) =>
persona.name.toLowerCase().startsWith(
message
.slice(message.lastIndexOf("@") + 1)
.split(/\s/)[0]
.toLowerCase()
)
);
const [assistantIconIndex, setAssistantIconIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
showSuggestions &&
filteredPersonas.length > 0 &&
(e.key === "Tab" || e.key == "Enter")
) {
e.preventDefault();
if (assistantIconIndex == filteredPersonas.length) {
window.open("/assistants/new", "_blank");
hideSuggestions();
setMessage("");
} else {
const option =
filteredPersonas[assistantIconIndex >= 0 ? assistantIconIndex : 0];
updateCurrentPersona(option);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
setAssistantIconIndex((assistantIconIndex) =>
Math.min(assistantIconIndex + 1, filteredPersonas.length)
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setAssistantIconIndex((assistantIconIndex) =>
Math.max(assistantIconIndex - 1, 0)
);
}
};
return (
<div>
<div className="flex justify-center pb-2 max-w-screen-lg mx-auto mb-2">
@ -90,9 +207,45 @@ export function ChatInputBar({
mx-auto
"
>
{showSuggestions && filteredPersonas.length > 0 && (
<div
ref={suggestionsRef}
className="text-sm absolute inset-x-0 top-0 w-full transform -translate-y-full"
>
<div className="rounded-lg py-1.5 bg-white border border-border-medium overflow-hidden shadow-lg mx-2 px-1.5 mt-2 rounded z-10">
{filteredPersonas.map((currentPersona, index) => (
<button
key={index}
className={`px-2 ${assistantIconIndex == index && "bg-hover"} rounded content-start flex gap-x-1 py-1.5 w-full hover:bg-hover cursor-pointer`}
onClick={() => {
updateCurrentPersona(currentPersona);
}}
>
<p className="font-bold ">{currentPersona.name}</p>
<p className="line-clamp-1">
{currentPersona.id == selectedAssistant.id &&
"(default) "}
{currentPersona.description}
</p>
</button>
))}
<a
key={filteredPersonas.length}
target="_blank"
className={`${assistantIconIndex == filteredPersonas.length && "bg-hover"} px-3 flex gap-x-1 py-2 w-full items-center hover:bg-hover-light cursor-pointer"`}
href="/assistants/new"
>
<FiPlus size={17} />
<p>Create a new assistant</p>
</a>
</div>
</div>
)}
<div>
<SelectedFilterDisplay filterManager={filterManager} />
</div>
<div
className="
opacity-100
@ -109,6 +262,38 @@ export function ChatInputBar({
[&:has(textarea:focus)]::ring-black
"
>
{alternativeAssistant && (
<div className="flex flex-wrap gap-y-1 gap-x-2 px-2 pt-1.5 w-full">
<div
ref={interactionsRef}
className="bg-background-subtle p-2 rounded-t-lg items-center flex w-full"
>
<AssistantIcon assistant={alternativeAssistant} border />
<p className="ml-3 text-strong my-auto">
{alternativeAssistant.name}
</p>
<div className="flex gap-x-1 ml-auto ">
<Tooltip
content={
<p className="max-w-xs flex flex-wrap">
{alternativeAssistant.description}
</p>
}
>
<button>
<Hoverable icon={FiInfo} />
</button>
</Tooltip>
<Hoverable
icon={FiX}
onClick={() => onSetSelectedAssistant(null)}
/>
</div>
</div>
</div>
)}
{files.length > 0 && (
<div className="flex flex-wrap gap-y-1 gap-x-2 px-2 pt-2">
{files.map((file) => (
@ -128,8 +313,11 @@ export function ChatInputBar({
))}
</div>
)}
<textarea
onPaste={handlePaste}
onKeyDownCapture={handleKeyDown}
onChange={handleInputChange}
ref={textAreaRef}
className={`
m-0
@ -149,7 +337,7 @@ export function ChatInputBar({
break-word
overscroll-contain
outline-none
placeholder-gray-400
placeholder-subtle
overflow-hidden
resize-none
pl-4
@ -163,7 +351,6 @@ export function ChatInputBar({
aria-multiline
placeholder="Send a message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
@ -250,9 +437,6 @@ export function ChatInputBar({
</div>
</div>
</div>
{/* <div className="text-center text-sm text-subtle mt-2">
Press "/" for shortcuts and useful prompts
</div> */}
</div>
);
}

View File

@ -70,6 +70,7 @@ export interface Message {
parentMessageId: number | null;
childrenMessageIds?: number[];
latestChildMessageId?: number | null;
alternateAssistantID?: number | null;
}
export interface BackendChatSession {
@ -95,6 +96,7 @@ export interface BackendMessage {
citations: CitationMap;
files: FileDescriptor[];
tool_calls: ToolCallFinalResult[];
alternate_assistant_id?: number | null;
}
export interface DocumentsResponse {

View File

@ -95,6 +95,7 @@ export async function* sendMessage({
temperature,
systemPromptOverride,
useExistingUserMessage,
alternateAssistantId,
}: {
message: string;
fileDescriptors: FileDescriptor[];
@ -114,15 +115,18 @@ export async function* sendMessage({
// if specified, will use the existing latest user message
// and will ignore the specified `message`
useExistingUserMessage?: boolean;
alternateAssistantId?: number;
}) {
const documentsAreSelected =
selectedDocumentIds && selectedDocumentIds.length > 0;
const sendMessageResponse = await fetch("/api/chat/send-message", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
alternate_assistant_id: alternateAssistantId,
chat_session_id: chatSessionId,
parent_message_id: parentMessageId,
message: message,
@ -381,6 +385,7 @@ export function processRawChatHistory(
message: messageInfo.message,
type: messageInfo.message_type as "user" | "assistant",
files: messageInfo.files,
alternateAssistantID: Number(messageInfo.alternate_assistant_id),
// only include these fields if this is an assistant message so that
// this is identical to what is computed at streaming time
...(messageInfo.message_type === "assistant"
@ -496,6 +501,32 @@ export function removeMessage(
completeMessageMap.delete(messageId);
}
export function checkAnyAssistantHasSearch(
messageHistory: Message[],
availablePersonas: Persona[],
livePersona: Persona
): boolean {
const response =
messageHistory.some((message) => {
if (
message.type !== "assistant" ||
message.alternateAssistantID === null
) {
return false;
}
const alternateAssistant = availablePersonas.find(
(persona) => persona.id === message.alternateAssistantID
);
return alternateAssistant
? personaIncludesRetrieval(alternateAssistant)
: false;
}) || personaIncludesRetrieval(livePersona);
return response;
}
export function personaIncludesRetrieval(selectedPersona: Persona) {
return selectedPersona.num_chunks !== 0;
}

View File

@ -38,6 +38,9 @@ import Prism from "prismjs";
import "prismjs/themes/prism-tomorrow.css";
import "./custom-code-styles.css";
import { Persona } from "@/app/admin/assistants/interfaces";
import { Button } from "@tremor/react";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
const TOOLS_WITH_CUSTOM_HANDLING = [
SEARCH_TOOL_NAME,
@ -80,6 +83,7 @@ function FileDisplay({ files }: { files: FileDescriptor[] }) {
}
export const AIMessage = ({
alternativeAssistant,
messageId,
content,
files,
@ -95,7 +99,10 @@ export const AIMessage = ({
handleSearchQueryEdit,
handleForceSearch,
retrievalDisabled,
currentPersona,
}: {
alternativeAssistant?: Persona | null;
currentPersona: Persona;
messageId: number | null;
content: string | JSX.Element;
files?: FileDescriptor[];
@ -165,14 +172,15 @@ export const AIMessage = ({
<div className="mx-auto w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar relative">
<div className="ml-8">
<div className="flex">
<div className="p-1 bg-ai rounded-lg h-fit my-auto">
<div className="text-inverted">
<FiCpu size={16} className="my-auto mx-auto" />
</div>
</div>
<AssistantIcon
size="small"
assistant={alternativeAssistant || currentPersona}
/>
<div className="font-bold text-emphasis ml-2 my-auto">
{personaName || "Danswer"}
{alternativeAssistant
? alternativeAssistant.name
: personaName || "Danswer"}
</div>
{query === undefined &&
@ -519,12 +527,6 @@ export const HumanMessage = ({
handleEditSubmit();
}
}}
// ref={(textarea) => {
// if (textarea) {
// textarea.selectionStart = textarea.selectionEnd =
// textarea.value.length;
// }
// }}
/>
<div className="flex justify-end mt-2 gap-2 pr-4">
<button

View File

@ -41,7 +41,6 @@ export default async function Page({
return (
<>
<InstantSSRAutoRefresh />
{shouldShowWelcomeModal && <WelcomeModal user={user} />}
{!shouldShowWelcomeModal && !shouldDisplaySourcesIncompleteModal && (
<ApiKeyModal user={user} />
@ -49,7 +48,6 @@ export default async function Page({
{shouldDisplaySourcesIncompleteModal && (
<NoCompleteSourcesModal ccPairs={ccPairs} />
)}
<ChatProvider
value={{
user,

View File

@ -10,6 +10,7 @@ import {
import { AIMessage, HumanMessage } from "../../message/Messages";
import { Button, Callout, Divider } from "@tremor/react";
import { useRouter } from "next/navigation";
import { useChatContext } from "@/components/context/ChatContext";
function BackToDanswerButton() {
const router = useRouter();
@ -30,6 +31,7 @@ export function SharedChatDisplay({
}: {
chatSession: BackendChatSession | null;
}) {
let { availablePersonas } = useChatContext();
if (!chatSession) {
return (
<div className="min-h-full w-full">
@ -44,6 +46,10 @@ export function SharedChatDisplay({
);
}
const currentPersona = availablePersonas.find(
(persona) => persona.id === chatSession.persona_id
);
const messages = buildLatestMessageChain(
processRawChatHistory(chatSession.messages)
);
@ -77,6 +83,7 @@ export function SharedChatDisplay({
} else {
return (
<AIMessage
currentPersona={currentPersona!}
key={message.messageId}
messageId={message.messageId}
content={message.message}

View File

@ -90,6 +90,7 @@ export default async function Home() {
}
let personas: Persona[] = [];
if (personaResponse?.ok) {
personas = await personaResponse.json();
} else {

View File

@ -1,7 +1,7 @@
import { Persona } from "@/app/admin/assistants/interfaces";
import React from "react";
function generatePastelColorFromId(id: string): string {
export function generatePastelColorFromId(id: string): string {
const hash = Array.from(id).reduce(
(acc, char) => acc + char.charCodeAt(0),
0
@ -12,16 +12,25 @@ function generatePastelColorFromId(id: string): string {
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
export function AssistantIcon({ assistant }: { assistant: Persona }) {
export function AssistantIcon({
assistant,
size,
border,
}: {
assistant: Persona;
size?: "small" | "medium" | "large";
border?: boolean;
}) {
const color = generatePastelColorFromId(assistant.id.toString());
return (
<div
className="
w-10
h-10
rounded-lg
"
className={`
${border && " border border-.5 border-border-strong "}
${(!size || size == "large") && "w-6 h-6"}
${size == "small" && "w-6 h-6"}
rounded-lg
`}
style={{ backgroundColor: color }}
/>
);

View File

@ -27,7 +27,7 @@ export const HealthCheckBanner = () => {
</p>
<a
href="/auth/login"
className="w-full mt-4 mx-auto rounded-md text-neutral-200 py-2 bg-neutral-800 text-center hover:bg-neutral-700 animtate duration-300 transition-bg "
className="w-full mt-4 mx-auto rounded-md text-light py-2 bg-background-dark text-center hover:bg-emphasis animtate duration-300 transition-bg "
>
Log in
</a>
@ -36,7 +36,7 @@ export const HealthCheckBanner = () => {
);
} else {
return (
<div className="text-xs mx-auto bg-gradient-to-r from-red-900 to-red-700 p-2 rounded-sm border-hidden text-gray-300">
<div className="text-xs mx-auto bg-gradient-to-r from-red-900 to-red-700 p-2 rounded-sm border-hidden text-light">
<p className="font-bold pb-1">The backend is currently unavailable.</p>
<p className="px-1">

View File

@ -29,6 +29,7 @@ export function Tooltip({
side={side}
align={align}
className="
bg-background-inverted
text-inverted
text-sm
@ -36,7 +37,7 @@ export function Tooltip({
py-1
px-2
shadow-lg
z-50
z-100
"
>
{content}

View File

@ -39,14 +39,17 @@ module.exports = {
colors: {
// background
background: "#f9fafb", // gray-50
"background-subtle": "#e5e7eb", // gray-200
"background-emphasis": "#f6f7f8",
"background-strong": "#eaecef",
"background-search": "#ffffff",
"background-custom-header": "#f3f4f6",
"background-inverted": "#000000",
"background-weak": "#f3f4f6", // gray-100
"background-dark": "#111827", // gray-900
// text or icons
light: "#e5e7eb", // gray-200
link: "#3b82f6", // blue-500
"link-hover": "#1d4ed8", // blue-700
subtle: "#6b7280", // gray-500