mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-06-05 04:31:03 +02:00
992 lines
34 KiB
TypeScript
992 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
FiEdit2,
|
|
FiChevronRight,
|
|
FiChevronLeft,
|
|
FiTool,
|
|
FiGlobe,
|
|
} from "react-icons/fi";
|
|
import { FeedbackType } from "../types";
|
|
import React, {
|
|
memo,
|
|
ReactNode,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import {
|
|
OnyxDocument,
|
|
FilteredOnyxDocument,
|
|
LoadedOnyxDocument,
|
|
} from "@/lib/search/interfaces";
|
|
import { SearchSummary } from "./SearchSummary";
|
|
|
|
import { SkippedSearch } from "./SkippedSearch";
|
|
import remarkGfm from "remark-gfm";
|
|
import { CopyButton } from "@/components/CopyButton";
|
|
import { ChatFileType, FileDescriptor, ToolCallMetadata } from "../interfaces";
|
|
import {
|
|
IMAGE_GENERATION_TOOL_NAME,
|
|
SEARCH_TOOL_NAME,
|
|
INTERNET_SEARCH_TOOL_NAME,
|
|
} from "../tools/constants";
|
|
import { ToolRunDisplay } from "../tools/ToolRunningAnimation";
|
|
import { Hoverable, HoverableIcon } from "@/components/Hoverable";
|
|
import { DocumentPreview } from "../files/documents/DocumentPreview";
|
|
import { InMessageImage } from "../files/images/InMessageImage";
|
|
import { CodeBlock } from "./CodeBlock";
|
|
import rehypePrism from "rehype-prism-plus";
|
|
|
|
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 { LikeFeedback, DislikeFeedback } from "@/components/icons/icons";
|
|
import {
|
|
CustomTooltip,
|
|
TooltipGroup,
|
|
} from "@/components/tooltip/CustomTooltip";
|
|
import { ValidSources } from "@/lib/types";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useMouseTracking } from "./hooks";
|
|
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 { MemoizedAnchor, MemoizedParagraph } from "./MemoizedTextComponents";
|
|
import { extractCodeText, preprocessLaTeX } from "./codeUtils";
|
|
import ToolResult from "../../../components/tools/ToolResult";
|
|
import CsvContent from "../../../components/tools/CSVContent";
|
|
import SourceCard, {
|
|
SeeMoreBlock,
|
|
} from "@/components/chat_search/sources/SourceCard";
|
|
|
|
import remarkMath from "remark-math";
|
|
import rehypeKatex from "rehype-katex";
|
|
import "katex/dist/katex.min.css";
|
|
|
|
const TOOLS_WITH_CUSTOM_HANDLING = [
|
|
SEARCH_TOOL_NAME,
|
|
INTERNET_SEARCH_TOOL_NAME,
|
|
IMAGE_GENERATION_TOOL_NAME,
|
|
];
|
|
|
|
function FileDisplay({
|
|
files,
|
|
alignBubble,
|
|
}: {
|
|
files: FileDescriptor[];
|
|
alignBubble?: boolean;
|
|
}) {
|
|
const [close, setClose] = useState(true);
|
|
const imageFiles = files.filter((file) => file.type === ChatFileType.IMAGE);
|
|
const nonImgFiles = files.filter(
|
|
(file) => file.type !== ChatFileType.IMAGE && file.type !== ChatFileType.CSV
|
|
);
|
|
|
|
const csvImgFiles = files.filter((file) => file.type == ChatFileType.CSV);
|
|
|
|
return (
|
|
<>
|
|
{nonImgFiles && nonImgFiles.length > 0 && (
|
|
<div
|
|
id="onyx-file"
|
|
className={` ${alignBubble && "ml-auto"} mt-2 auto mb-4`}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{nonImgFiles.map((file) => {
|
|
return (
|
|
<div key={file.id} className="w-fit">
|
|
<DocumentPreview
|
|
fileName={file.name || file.id}
|
|
maxWidth="max-w-64"
|
|
alignBubble={alignBubble}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{imageFiles && imageFiles.length > 0 && (
|
|
<div
|
|
id="onyx-image"
|
|
className={` ${alignBubble && "ml-auto"} mt-2 auto mb-4`}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{imageFiles.map((file) => {
|
|
return <InMessageImage key={file.id} fileId={file.id} />;
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{csvImgFiles && csvImgFiles.length > 0 && (
|
|
<div className={` ${alignBubble && "ml-auto"} mt-2 auto mb-4`}>
|
|
<div className="flex flex-col gap-2">
|
|
{csvImgFiles.map((file) => {
|
|
return (
|
|
<div key={file.id} className="w-fit">
|
|
{close ? (
|
|
<>
|
|
<ToolResult
|
|
csvFileDescriptor={file}
|
|
close={() => setClose(false)}
|
|
contentComponent={CsvContent}
|
|
/>
|
|
</>
|
|
) : (
|
|
<DocumentPreview
|
|
open={() => setClose(true)}
|
|
fileName={file.name || file.id}
|
|
maxWidth="max-w-64"
|
|
alignBubble={alignBubble}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export const AIMessage = ({
|
|
regenerate,
|
|
overriddenModel,
|
|
selectedMessageForDocDisplay,
|
|
continueGenerating,
|
|
shared,
|
|
isActive,
|
|
toggleDocumentSelection,
|
|
alternativeAssistant,
|
|
docs,
|
|
messageId,
|
|
documentSelectionToggled,
|
|
content,
|
|
files,
|
|
selectedDocuments,
|
|
query,
|
|
citedDocuments,
|
|
toolCall,
|
|
isComplete,
|
|
hasDocs,
|
|
handleFeedback,
|
|
handleShowRetrieved,
|
|
handleSearchQueryEdit,
|
|
handleForceSearch,
|
|
retrievalDisabled,
|
|
currentPersona,
|
|
otherMessagesCanSwitchTo,
|
|
onMessageSelection,
|
|
setPresentingDocument,
|
|
index,
|
|
}: {
|
|
index?: number;
|
|
selectedMessageForDocDisplay?: number | null;
|
|
shared?: boolean;
|
|
isActive?: boolean;
|
|
continueGenerating?: () => void;
|
|
otherMessagesCanSwitchTo?: number[];
|
|
onMessageSelection?: (messageId: number) => void;
|
|
selectedDocuments?: OnyxDocument[] | null;
|
|
toggleDocumentSelection?: () => void;
|
|
docs?: OnyxDocument[] | null;
|
|
alternativeAssistant?: Persona | null;
|
|
currentPersona: Persona;
|
|
messageId: number | null;
|
|
content: string | JSX.Element;
|
|
documentSelectionToggled?: boolean;
|
|
files?: FileDescriptor[];
|
|
query?: string;
|
|
citedDocuments?: [string, OnyxDocument][] | null;
|
|
toolCall?: ToolCallMetadata | null;
|
|
isComplete?: boolean;
|
|
hasDocs?: boolean;
|
|
handleFeedback?: (feedbackType: FeedbackType) => void;
|
|
handleShowRetrieved?: (messageNumber: number | null) => void;
|
|
handleSearchQueryEdit?: (query: string) => void;
|
|
handleForceSearch?: () => void;
|
|
retrievalDisabled?: boolean;
|
|
overriddenModel?: string;
|
|
regenerate?: (modelOverRide: LlmOverride) => Promise<void>;
|
|
setPresentingDocument?: (document: OnyxDocument) => void;
|
|
}) => {
|
|
const toolCallGenerating = toolCall && !toolCall.tool_result;
|
|
|
|
const processContent = (content: string | JSX.Element) => {
|
|
if (typeof content !== "string") {
|
|
return content;
|
|
}
|
|
|
|
const codeBlockRegex = /```(\w*)\n[\s\S]*?```|```[\s\S]*?$/g;
|
|
const matches = content.match(codeBlockRegex);
|
|
|
|
if (matches) {
|
|
content = matches.reduce((acc, match) => {
|
|
if (!match.match(/```\w+/)) {
|
|
return acc.replace(match, match.replace("```", "```plaintext"));
|
|
}
|
|
return acc;
|
|
}, content);
|
|
|
|
const lastMatch = matches[matches.length - 1];
|
|
if (!lastMatch.endsWith("```")) {
|
|
return preprocessLaTeX(content);
|
|
}
|
|
}
|
|
|
|
return (
|
|
preprocessLaTeX(content) +
|
|
(!isComplete && !toolCallGenerating ? " [*]() " : "")
|
|
);
|
|
};
|
|
|
|
const finalContent = processContent(content as string);
|
|
|
|
const [isRegenerateHovered, setIsRegenerateHovered] = useState(false);
|
|
const [isRegenerateDropdownVisible, setIsRegenerateDropdownVisible] =
|
|
useState(false);
|
|
const { isHovering, trackedElementRef, hoverElementRef } = useMouseTracking();
|
|
|
|
const settings = useContext(SettingsContext);
|
|
// this is needed to give Prism a chance to load
|
|
|
|
const selectedDocumentIds =
|
|
selectedDocuments?.map((document) => document.document_id) || [];
|
|
const citedDocumentIds: string[] = [];
|
|
|
|
citedDocuments?.forEach((doc) => {
|
|
citedDocumentIds.push(doc[1].document_id);
|
|
});
|
|
|
|
if (!isComplete) {
|
|
const trimIncompleteCodeSection = (
|
|
content: string | JSX.Element
|
|
): string | JSX.Element => {
|
|
if (typeof content === "string") {
|
|
const pattern = /```[a-zA-Z]+[^\s]*$/;
|
|
const match = content.match(pattern);
|
|
if (match && match.index && match.index > 3) {
|
|
const newContent = content.slice(0, match.index - 3);
|
|
return newContent;
|
|
}
|
|
return content;
|
|
}
|
|
return content;
|
|
};
|
|
content = trimIncompleteCodeSection(content);
|
|
}
|
|
|
|
let filteredDocs: FilteredOnyxDocument[] = [];
|
|
|
|
if (docs) {
|
|
filteredDocs = docs
|
|
.filter(
|
|
(doc, index, self) =>
|
|
doc.document_id &&
|
|
doc.document_id !== "" &&
|
|
index === self.findIndex((d) => d.document_id === doc.document_id)
|
|
)
|
|
.filter((doc) => {
|
|
return citedDocumentIds.includes(doc.document_id);
|
|
})
|
|
.map((doc: OnyxDocument, ind: number) => {
|
|
return {
|
|
...doc,
|
|
included: selectedDocumentIds.includes(doc.document_id),
|
|
};
|
|
});
|
|
}
|
|
|
|
const paragraphCallback = useCallback(
|
|
(props: any) => <MemoizedParagraph>{props.children}</MemoizedParagraph>,
|
|
[]
|
|
);
|
|
|
|
const anchorCallback = useCallback(
|
|
(props: any) => (
|
|
<MemoizedAnchor
|
|
updatePresentingDocument={setPresentingDocument}
|
|
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: anchorCallback,
|
|
p: paragraphCallback,
|
|
code: ({ node, className, children }: any) => {
|
|
const codeText = extractCodeText(
|
|
node,
|
|
finalContent as string,
|
|
children
|
|
);
|
|
|
|
return (
|
|
<CodeBlock className={className} codeText={codeText}>
|
|
{children}
|
|
</CodeBlock>
|
|
);
|
|
},
|
|
}),
|
|
[anchorCallback, paragraphCallback, finalContent]
|
|
);
|
|
|
|
const renderedMarkdown = useMemo(() => {
|
|
return (
|
|
<ReactMarkdown
|
|
className="prose max-w-full text-base"
|
|
components={markdownComponents}
|
|
remarkPlugins={[remarkGfm, remarkMath]}
|
|
rehypePlugins={[[rehypePrism, { ignoreMissing: true }], rehypeKatex]}
|
|
>
|
|
{finalContent as string}
|
|
</ReactMarkdown>
|
|
);
|
|
}, [finalContent, markdownComponents]);
|
|
|
|
const includeMessageSwitcher =
|
|
currentMessageInd !== undefined &&
|
|
onMessageSelection &&
|
|
otherMessagesCanSwitchTo &&
|
|
otherMessagesCanSwitchTo.length > 1;
|
|
return (
|
|
<div
|
|
id="onyx-ai-message"
|
|
ref={trackedElementRef}
|
|
className={`py-5 ml-4 px-5 relative flex `}
|
|
>
|
|
<div
|
|
className={`mx-auto ${
|
|
shared ? "w-full" : "w-[90%]"
|
|
} max-w-message-max`}
|
|
>
|
|
<div className={`desktop:mr-12 ${!shared && "mobile:ml-0 md:ml-8"}`}>
|
|
<div className="flex">
|
|
<AssistantIcon
|
|
size="small"
|
|
assistant={alternativeAssistant || currentPersona}
|
|
/>
|
|
|
|
<div className="w-full">
|
|
<div className="max-w-message-max break-words">
|
|
<div className="w-full ml-4">
|
|
<div className="max-w-message-max break-words">
|
|
{!toolCall || toolCall.tool_name === SEARCH_TOOL_NAME ? (
|
|
<>
|
|
{query !== undefined &&
|
|
handleShowRetrieved !== undefined &&
|
|
!retrievalDisabled && (
|
|
<div className="mb-1">
|
|
<SearchSummary
|
|
index={index || 0}
|
|
query={query}
|
|
finished={toolCall?.tool_result != undefined}
|
|
hasDocs={hasDocs || false}
|
|
messageId={messageId}
|
|
handleShowRetrieved={handleShowRetrieved}
|
|
handleSearchQueryEdit={handleSearchQueryEdit}
|
|
/>
|
|
</div>
|
|
)}
|
|
{handleForceSearch &&
|
|
content &&
|
|
query === undefined &&
|
|
!hasDocs &&
|
|
!retrievalDisabled && (
|
|
<div className="mb-1">
|
|
<SkippedSearch
|
|
handleForceSearch={handleForceSearch}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : null}
|
|
|
|
{toolCall &&
|
|
!TOOLS_WITH_CUSTOM_HANDLING.includes(
|
|
toolCall.tool_name
|
|
) && (
|
|
<ToolRunDisplay
|
|
toolName={
|
|
toolCall.tool_result && content
|
|
? `Used "${toolCall.tool_name}"`
|
|
: `Using "${toolCall.tool_name}"`
|
|
}
|
|
toolLogo={
|
|
<FiTool size={15} className="my-auto mr-1" />
|
|
}
|
|
isRunning={!toolCall.tool_result || !content}
|
|
/>
|
|
)}
|
|
|
|
{toolCall &&
|
|
(!files || files.length == 0) &&
|
|
toolCall.tool_name === IMAGE_GENERATION_TOOL_NAME &&
|
|
!toolCall.tool_result && <GeneratingImageDisplay />}
|
|
|
|
{toolCall &&
|
|
toolCall.tool_name === INTERNET_SEARCH_TOOL_NAME && (
|
|
<ToolRunDisplay
|
|
toolName={
|
|
toolCall.tool_result
|
|
? `Searched the internet`
|
|
: `Searching the internet`
|
|
}
|
|
toolLogo={
|
|
<FiGlobe size={15} className="my-auto mr-1" />
|
|
}
|
|
isRunning={!toolCall.tool_result}
|
|
/>
|
|
)}
|
|
|
|
{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}
|
|
setPresentingDocument={
|
|
setPresentingDocument
|
|
}
|
|
/>
|
|
))}
|
|
<SeeMoreBlock
|
|
documentSelectionToggled={
|
|
(documentSelectionToggled &&
|
|
selectedMessageForDocDisplay === messageId) ||
|
|
false
|
|
}
|
|
toggleDocumentSelection={toggleDocumentSelection}
|
|
uniqueSources={uniqueSources}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{content || files ? (
|
|
<>
|
|
<FileDisplay files={files || []} />
|
|
|
|
{typeof content === "string" ? (
|
|
<div className="overflow-x-visible max-w-content-max">
|
|
{renderedMarkdown}
|
|
</div>
|
|
) : (
|
|
content
|
|
)}
|
|
</>
|
|
) : isComplete ? null : (
|
|
<></>
|
|
)}
|
|
</div>
|
|
|
|
{handleFeedback &&
|
|
(isActive ? (
|
|
<div
|
|
className={`
|
|
flex md:flex-row gap-x-0.5 mt-1
|
|
transition-transform duration-300 ease-in-out
|
|
transform opacity-100 translate-y-0"
|
|
`}
|
|
>
|
|
<TooltipGroup>
|
|
<div className="flex justify-start w-full gap-x-0.5">
|
|
{includeMessageSwitcher && (
|
|
<div className="-mx-1 mr-auto">
|
|
<MessageSwitcher
|
|
currentPage={currentMessageInd + 1}
|
|
totalPages={otherMessagesCanSwitchTo.length}
|
|
handlePrevious={() => {
|
|
onMessageSelection(
|
|
otherMessagesCanSwitchTo[
|
|
currentMessageInd - 1
|
|
]
|
|
);
|
|
}}
|
|
handleNext={() => {
|
|
onMessageSelection(
|
|
otherMessagesCanSwitchTo[
|
|
currentMessageInd + 1
|
|
]
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<CustomTooltip showTick line content="Copy!">
|
|
<CopyButton content={content.toString()} />
|
|
</CustomTooltip>
|
|
<CustomTooltip showTick line content="Good response!">
|
|
<HoverableIcon
|
|
icon={<LikeFeedback />}
|
|
onClick={() => handleFeedback("like")}
|
|
/>
|
|
</CustomTooltip>
|
|
<CustomTooltip showTick line content="Bad response!">
|
|
<HoverableIcon
|
|
icon={<DislikeFeedback size={16} />}
|
|
onClick={() => handleFeedback("dislike")}
|
|
/>
|
|
</CustomTooltip>
|
|
{regenerate && (
|
|
<CustomTooltip
|
|
disabled={isRegenerateDropdownVisible}
|
|
showTick
|
|
line
|
|
content="Regenerate!"
|
|
>
|
|
<RegenerateOption
|
|
onDropdownVisibleChange={
|
|
setIsRegenerateDropdownVisible
|
|
}
|
|
onHoverChange={setIsRegenerateHovered}
|
|
selectedAssistant={currentPersona!}
|
|
regenerate={regenerate}
|
|
overriddenModel={overriddenModel}
|
|
/>
|
|
</CustomTooltip>
|
|
)}
|
|
</TooltipGroup>
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={hoverElementRef}
|
|
className={`
|
|
absolute -bottom-5
|
|
z-10
|
|
invisible ${
|
|
(isHovering ||
|
|
isRegenerateHovered ||
|
|
settings?.isMobile) &&
|
|
"!visible"
|
|
}
|
|
opacity-0 ${
|
|
(isHovering ||
|
|
isRegenerateHovered ||
|
|
settings?.isMobile) &&
|
|
"!opacity-100"
|
|
}
|
|
translate-y-2 ${
|
|
(isHovering || settings?.isMobile) && "!translate-y-0"
|
|
}
|
|
transition-transform duration-300 ease-in-out
|
|
flex md:flex-row gap-x-0.5 bg-background-125/40 -mx-1.5 p-1.5 rounded-lg
|
|
`}
|
|
>
|
|
<TooltipGroup>
|
|
<div className="flex justify-start w-full gap-x-0.5">
|
|
{includeMessageSwitcher && (
|
|
<div className="-mx-1 mr-auto">
|
|
<MessageSwitcher
|
|
currentPage={currentMessageInd + 1}
|
|
totalPages={otherMessagesCanSwitchTo.length}
|
|
handlePrevious={() => {
|
|
onMessageSelection(
|
|
otherMessagesCanSwitchTo[
|
|
currentMessageInd - 1
|
|
]
|
|
);
|
|
}}
|
|
handleNext={() => {
|
|
onMessageSelection(
|
|
otherMessagesCanSwitchTo[
|
|
currentMessageInd + 1
|
|
]
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<CustomTooltip showTick line content="Copy!">
|
|
<CopyButton content={content.toString()} />
|
|
</CustomTooltip>
|
|
|
|
<CustomTooltip showTick line content="Good response!">
|
|
<HoverableIcon
|
|
icon={<LikeFeedback />}
|
|
onClick={() => handleFeedback("like")}
|
|
/>
|
|
</CustomTooltip>
|
|
|
|
<CustomTooltip showTick line content="Bad response!">
|
|
<HoverableIcon
|
|
icon={<DislikeFeedback size={16} />}
|
|
onClick={() => handleFeedback("dislike")}
|
|
/>
|
|
</CustomTooltip>
|
|
{regenerate && (
|
|
<CustomTooltip
|
|
disabled={isRegenerateDropdownVisible}
|
|
showTick
|
|
line
|
|
content="Regenerate!"
|
|
>
|
|
<RegenerateOption
|
|
selectedAssistant={currentPersona!}
|
|
onDropdownVisibleChange={
|
|
setIsRegenerateDropdownVisible
|
|
}
|
|
regenerate={regenerate}
|
|
overriddenModel={overriddenModel}
|
|
onHoverChange={setIsRegenerateHovered}
|
|
/>
|
|
</CustomTooltip>
|
|
)}
|
|
</TooltipGroup>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{(!toolCall || toolCall.tool_name === SEARCH_TOOL_NAME) &&
|
|
!query &&
|
|
continueGenerating && (
|
|
<ContinueGenerating handleContinueGenerating={continueGenerating} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function MessageSwitcher({
|
|
currentPage,
|
|
totalPages,
|
|
handlePrevious,
|
|
handleNext,
|
|
}: {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
handlePrevious: () => void;
|
|
handleNext: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center text-sm space-x-0.5">
|
|
<Hoverable
|
|
icon={FiChevronLeft}
|
|
onClick={currentPage === 1 ? undefined : handlePrevious}
|
|
/>
|
|
|
|
<span className="text-emphasis select-none">
|
|
{currentPage} / {totalPages}
|
|
</span>
|
|
|
|
<Hoverable
|
|
icon={FiChevronRight}
|
|
onClick={currentPage === totalPages ? undefined : handleNext}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const HumanMessage = ({
|
|
content,
|
|
files,
|
|
messageId,
|
|
otherMessagesCanSwitchTo,
|
|
onEdit,
|
|
onMessageSelection,
|
|
shared,
|
|
stopGenerating = () => null,
|
|
}: {
|
|
shared?: boolean;
|
|
content: string;
|
|
files?: FileDescriptor[];
|
|
messageId?: number | null;
|
|
otherMessagesCanSwitchTo?: number[];
|
|
onEdit?: (editedContent: string) => void;
|
|
onMessageSelection?: (messageId: number) => void;
|
|
stopGenerating?: () => void;
|
|
}) => {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editedContent, setEditedContent] = useState(content);
|
|
|
|
useEffect(() => {
|
|
if (!isEditing) {
|
|
setEditedContent(content);
|
|
}
|
|
}, [content, isEditing]);
|
|
|
|
useEffect(() => {
|
|
if (textareaRef.current) {
|
|
// Focus the textarea
|
|
textareaRef.current.focus();
|
|
// Move the cursor to the end of the text
|
|
textareaRef.current.selectionStart = textareaRef.current.value.length;
|
|
textareaRef.current.selectionEnd = textareaRef.current.value.length;
|
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
|
}
|
|
}, [isEditing]);
|
|
|
|
const handleEditSubmit = () => {
|
|
onEdit?.(editedContent);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
const currentMessageInd = messageId
|
|
? otherMessagesCanSwitchTo?.indexOf(messageId)
|
|
: undefined;
|
|
|
|
return (
|
|
<div
|
|
id="onyx-human-message"
|
|
className="pt-5 pb-1 px-2 lg:px-5 flex -mr-6 relative"
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
<div
|
|
className={`text-user-text mx-auto ${
|
|
shared ? "w-full" : "w-[90%]"
|
|
} max-w-[790px]`}
|
|
>
|
|
<div className="xl:ml-8">
|
|
<div className="flex flex-col mr-4">
|
|
<FileDisplay alignBubble files={files || []} />
|
|
|
|
<div className="flex justify-end">
|
|
<div className="w-full ml-8 flex w-full w-[800px] break-words">
|
|
{isEditing ? (
|
|
<div className="w-full">
|
|
<div
|
|
className={`
|
|
opacity-100
|
|
w-full
|
|
flex
|
|
flex-col
|
|
border
|
|
border-border
|
|
rounded-lg
|
|
bg-background-emphasis
|
|
pb-2
|
|
[&:has(textarea:focus)]::ring-1
|
|
[&:has(textarea:focus)]::ring-black
|
|
`}
|
|
>
|
|
<textarea
|
|
ref={textareaRef}
|
|
className={`
|
|
m-0
|
|
w-full
|
|
h-auto
|
|
shrink
|
|
border-0
|
|
rounded-lg
|
|
overflow-y-hidden
|
|
bg-background-emphasis
|
|
whitespace-normal
|
|
break-word
|
|
overscroll-contain
|
|
outline-none
|
|
placeholder-gray-400
|
|
resize-none
|
|
text-text-editing-message
|
|
pl-4
|
|
overflow-y-auto
|
|
pr-12
|
|
py-4`}
|
|
aria-multiline
|
|
role="textarea"
|
|
value={editedContent}
|
|
style={{ scrollbarWidth: "thin" }}
|
|
onChange={(e) => {
|
|
setEditedContent(e.target.value);
|
|
textareaRef.current!.style.height = "auto";
|
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setEditedContent(content);
|
|
setIsEditing(false);
|
|
}
|
|
// Submit edit if "Command Enter" is pressed, like in ChatGPT
|
|
if (e.key === "Enter" && e.metaKey) {
|
|
handleEditSubmit();
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex justify-end mt-2 gap-2 pr-4">
|
|
<button
|
|
className={`
|
|
w-fit
|
|
bg-accent
|
|
text-inverted
|
|
text-sm
|
|
rounded-lg
|
|
inline-flex
|
|
items-center
|
|
justify-center
|
|
flex-shrink-0
|
|
font-medium
|
|
min-h-[38px]
|
|
py-2
|
|
px-3
|
|
hover:bg-accent-hover
|
|
`}
|
|
onClick={handleEditSubmit}
|
|
>
|
|
Submit
|
|
</button>
|
|
<button
|
|
className={`
|
|
inline-flex
|
|
items-center
|
|
justify-center
|
|
flex-shrink-0
|
|
font-medium
|
|
min-h-[38px]
|
|
py-2
|
|
px-3
|
|
w-fit
|
|
bg-background-strong
|
|
text-sm
|
|
rounded-lg
|
|
hover:bg-hover-emphasis
|
|
`}
|
|
onClick={() => {
|
|
setEditedContent(content);
|
|
setIsEditing(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : typeof content === "string" ? (
|
|
<>
|
|
<div className="ml-auto mr-1 my-auto">
|
|
{onEdit &&
|
|
isHovered &&
|
|
!isEditing &&
|
|
(!files || files.length === 0) ? (
|
|
<TooltipProvider delayDuration={1000}>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<HoverableIcon
|
|
icon={<FiEdit2 className="text-gray-600" />}
|
|
onClick={() => {
|
|
setIsEditing(true);
|
|
setIsHovered(false);
|
|
}}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Edit</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
) : (
|
|
<div className="w-7" />
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={`${
|
|
!(
|
|
onEdit &&
|
|
isHovered &&
|
|
!isEditing &&
|
|
(!files || files.length === 0)
|
|
) && "ml-auto"
|
|
} relative flex-none max-w-[70%] mb-auto whitespace-break-spaces rounded-3xl bg-user px-5 py-2.5`}
|
|
>
|
|
{content}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{onEdit &&
|
|
isHovered &&
|
|
!isEditing &&
|
|
(!files || files.length === 0) ? (
|
|
<div className="my-auto">
|
|
<Hoverable
|
|
icon={FiEdit2}
|
|
onClick={() => {
|
|
setIsEditing(true);
|
|
setIsHovered(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="h-[27px]" />
|
|
)}
|
|
<div className="ml-auto rounded-lg p-1">{content}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row gap-x-0.5 mt-1">
|
|
{currentMessageInd !== undefined &&
|
|
onMessageSelection &&
|
|
otherMessagesCanSwitchTo &&
|
|
otherMessagesCanSwitchTo.length > 1 && (
|
|
<div className="ml-auto mr-3">
|
|
<MessageSwitcher
|
|
currentPage={currentMessageInd + 1}
|
|
totalPages={otherMessagesCanSwitchTo.length}
|
|
handlePrevious={() => {
|
|
stopGenerating();
|
|
onMessageSelection(
|
|
otherMessagesCanSwitchTo[currentMessageInd - 1]
|
|
);
|
|
}}
|
|
handleNext={() => {
|
|
stopGenerating();
|
|
onMessageSelection(
|
|
otherMessagesCanSwitchTo[currentMessageInd + 1]
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|