github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
16b7924
import { Card, Stack } from "@mantine/core";
import { usePubSub } from "create-pubsub/react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import throttle from "throttleit";
import { generateFollowUpQuestion } from "../../modules/followUpQuestions";
import {
getCurrentSearchRunId,
saveChatMessageForQuery,
updateSearchResults,
} from "../../modules/history";
import { handleEnterKeyDown } from "../../modules/keyboard";
import { addLogEntry } from "../../modules/logEntries";
import {
chatGenerationStatePubSub,
chatInputPubSub,
followUpQuestionPubSub,
getSettings,
imageSearchResultsPubSub,
queryPubSub,
settingsPubSub,
suppressNextFollowUpPubSub,
textSearchResultsPubSub,
updateImageSearchResults,
updateLlmTextSearchResults,
updateTextSearchResults,
} from "../../modules/pubSub";
import { generateRelatedSearchQuery } from "../../modules/relatedSearchQuery";
import { searchImages, searchText } from "../../modules/search";
import { generateChatResponse } from "../../modules/textGeneration";
import type { ChatMessage } from "../../modules/types";
import ChatHeader from "./ChatHeader";
import ChatInputArea from "./ChatInputArea";
import MessageList from "./MessageList";
interface ChatInterfaceProps {
initialQuery?: string;
initialResponse?: string;
initialMessages?: ChatMessage[];
suppressInitialFollowUp?: boolean;
}
export default function ChatInterface({
initialQuery,
initialResponse,
initialMessages,
suppressInitialFollowUp,
}: ChatInterfaceProps) {
const initialMessagesArray =
initialMessages &&
initialMessages.length > 0 &&
initialQuery &&
initialResponse
? [
{ role: "user" as const, content: initialQuery },
{ role: "assistant" as const, content: initialResponse },
...initialMessages,
]
: initialMessages || [];
const [messages, setMessages] = useState<ChatMessage[]>(initialMessagesArray);
const [input, setInput] = usePubSub(chatInputPubSub);
const [generationState, setGenerationState] = usePubSub(
chatGenerationStatePubSub,
);
const [, setFollowUpQuestion] = usePubSub(followUpQuestionPubSub);
const [textSearchResults] = usePubSub(textSearchResultsPubSub);
const [imageSearchResults] = usePubSub(imageSearchResultsPubSub);
const [currentQuery] = usePubSub(queryPubSub);
const [suppressNextFollowUp] = usePubSub(suppressNextFollowUpPubSub);
const [previousFollowUpQuestions, setPreviousFollowUpQuestions] = useState<
string[]
>([]);
const [settings] = usePubSub(settingsPubSub);
const [streamedResponse, setStreamedResponse] = useState("");
const hasInitialized = useRef(false);
const prevInitialMessagesRef = useRef<ChatMessage[] | undefined>(undefined);
const updateStreamedResponse = useCallback(
throttle((response: string) => {
setStreamedResponse(response);
}, 1000 / 12),
[],
);
const regenerateFollowUpQuestion = useCallback(
async (currentQuery: string, currentResponse: string) => {
if (suppressNextFollowUp) return;
if (!currentResponse || !currentQuery.trim()) return;
try {
setGenerationState({
isGeneratingResponse: false,
isGeneratingFollowUpQuestion: true,
});
const newQuestion = await generateFollowUpQuestion({
topic: currentQuery,
currentContent: currentResponse,
previousQuestions: previousFollowUpQuestions,
});
setPreviousFollowUpQuestions((prev) =>
[...prev, newQuestion].slice(-5),
);
setFollowUpQuestion(newQuestion);
setGenerationState({
isGeneratingResponse: false,
isGeneratingFollowUpQuestion: false,
});
} catch (_) {
setFollowUpQuestion("");
setGenerationState({
isGeneratingResponse: false,
isGeneratingFollowUpQuestion: false,
});
}
},
[
setFollowUpQuestion,
setGenerationState,
previousFollowUpQuestions,
suppressNextFollowUp,
],
);
useEffect(() => {
const messagesChanged =
!prevInitialMessagesRef.current ||
JSON.stringify(prevInitialMessagesRef.current) !==
JSON.stringify(initialMessages);
if (!messagesChanged) return;
prevInitialMessagesRef.current = initialMessages;
const newInitialMessagesArray =
initialMessages &&
initialMessages.length > 0 &&
initialQuery &&
initialResponse
? [
{ role: "user" as const, content: initialQuery },
{ role: "assistant" as const, content: initialResponse },
...initialMessages,
]
: initialMessages || [];
if (newInitialMessagesArray.length > 0) {
setMessages(newInitialMessagesArray);
} else if (initialQuery && initialResponse) {
setMessages([
{ role: "user", content: initialQuery },
{ role: "assistant", content: initialResponse },
]);
}
}, [initialQuery, initialResponse, initialMessages]);
useEffect(() => {
if (suppressNextFollowUp) {
hasInitialized.current = true;
return;
}
if (suppressInitialFollowUp) return;
if (hasInitialized.current) return;
if (initialMessages && initialMessages.length > 0) {
const lastAssistant = messages
.filter((m) => m.role === "assistant")
.pop();
const lastUser = messages.filter((m) => m.role === "user").pop();
if (lastUser && lastAssistant) {
regenerateFollowUpQuestion(lastUser.content, lastAssistant.content);
hasInitialized.current = true;
}
} else if (messages.length >= 2 && initialQuery && initialResponse) {
regenerateFollowUpQuestion(initialQuery, initialResponse);
hasInitialized.current = true;
}
}, [
initialQuery,
initialResponse,
initialMessages,
messages,
regenerateFollowUpQuestion,
suppressInitialFollowUp,
suppressNextFollowUp,
]);
useEffect(() => {
return () => {
setFollowUpQuestion("");
setPreviousFollowUpQuestions([]);
};
}, [setFollowUpQuestion]);
const handleEditMessage = useCallback(
(absoluteIndex: number) => {
const target = messages[absoluteIndex];
if (!target || target.role !== "user") return;
setInput(target.content);
setMessages(messages.slice(0, absoluteIndex));
setFollowUpQuestion("");
},
[messages, setInput, setFollowUpQuestion],
);
const handleRegenerateResponse = useCallback(async () => {
if (
generationState.isGeneratingResponse ||
messages.length < 3 ||
messages[messages.length - 1].role !== "assistant"
)
return;
const history = messages.slice(0, -1);
const lastUser = history[history.length - 1];
setMessages(history);
setGenerationState({ ...generationState, isGeneratingResponse: true });
setFollowUpQuestion("");
setStreamedResponse("");
try {
const finalResponse = await generateChatResponse(
history,
updateStreamedResponse,
);
setMessages((prev) => [
...prev,
{ role: "assistant", content: finalResponse },
]);
addLogEntry("AI response re-generated");
if (lastUser?.role === "user") {
await regenerateFollowUpQuestion(lastUser.content, finalResponse);
}
} catch (error) {
addLogEntry(`Error re-generating response: ${error}`);
} finally {
setGenerationState({ ...generationState, isGeneratingResponse: false });
}
}, [
generationState,
messages,
regenerateFollowUpQuestion,
setFollowUpQuestion,
setGenerationState,
updateStreamedResponse,
]);
const handleSend = useCallback(
async (textToSend?: string) => {
const currentInput = textToSend ?? input;
if (currentInput.trim() === "" || generationState.isGeneratingResponse)
return;
const userMessage: ChatMessage = { role: "user", content: currentInput };
const newMessages: ChatMessage[] = [...messages, userMessage];
setMessages(newMessages);
if (!textToSend) setInput("");
setGenerationState({
...generationState,
isGeneratingResponse: true,
});
setFollowUpQuestion("");
setStreamedResponse("");
try {
const relatedQuery = await generateRelatedSearchQuery([...newMessages]);
const searchQuery = relatedQuery || currentInput;
if (settings.enableTextSearch) {
const freshResults = await searchText(
searchQuery,
settings.searchResultsLimit,
);
if (freshResults.length > 0) {
const existingUrls = new Set(
textSearchResults.map(([, , url]) => url),
);
const uniqueFreshResults = freshResults.filter(
([, , url]) => !existingUrls.has(url),
);
updateLlmTextSearchResults(
freshResults.slice(0, getSettings().searchResultsToConsider),
);
if (uniqueFreshResults.length > 0) {
const updatedResults = [
...textSearchResults,
...uniqueFreshResults,
];
updateTextSearchResults(updatedResults);
updateSearchResults(getCurrentSearchRunId(), {
textResults: {
type: "text",
items: updatedResults.map(([title, snippet, url]) => ({
title,
url,
snippet,
})),
},
});
}
}
}
if (settings.enableImageSearch) {
searchImages(searchQuery, settings.searchResultsLimit)
.then((imageResults) => {
if (imageResults.length > 0) {
const existingUrls = new Set(
imageSearchResults.map(([, url]) => url),
);
const uniqueFreshResults = imageResults.filter(
([, url]) => !existingUrls.has(url),
);
if (uniqueFreshResults.length > 0) {
const updatedImageResults = [
...uniqueFreshResults,
...imageSearchResults,
];
updateImageSearchResults(updatedImageResults);
updateSearchResults(getCurrentSearchRunId(), {
imageResults: {
type: "image",
items: updatedImageResults.map(
([title, url, thumbnailUrl, sourceUrl]) => ({
title,
url,
thumbnailUrl,
sourceUrl,
}),
),
},
});
}
}
})
.catch((error) => {
addLogEntry(`Error in follow-up image search: ${error}`);
});
}
} catch (error) {
addLogEntry(`Error in follow-up search: ${error}`);
}
try {
const finalResponse = await generateChatResponse(
newMessages,
updateStreamedResponse,
);
setMessages((prevMessages) => [
...prevMessages,
{ role: "assistant", content: finalResponse },
]);
addLogEntry("AI response completed");
await saveChatMessageForQuery(currentQuery, "user", currentInput);
await saveChatMessageForQuery(currentQuery, "assistant", finalResponse);
await regenerateFollowUpQuestion(currentInput, finalResponse);
} catch (error) {
addLogEntry(`Error in chat response: ${error}`);
setMessages((prevMessages) => [
...prevMessages,
{
role: "assistant",
content:
"Sorry, I encountered an error while generating a response.",
},
]);
} finally {
setGenerationState({
...generationState,
isGeneratingResponse: false,
});
}
},
[
generationState,
messages,
settings,
input,
regenerateFollowUpQuestion,
setFollowUpQuestion,
setGenerationState,
setInput,
updateStreamedResponse,
currentQuery,
textSearchResults,
imageSearchResults,
],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
handleEnterKeyDown(event, settings, handleSend);
},
[settings, handleSend],
);
return (
<Card withBorder shadow="sm" radius="md">
<Card.Section withBorder inheritPadding py="xs">
<ChatHeader messages={messages} />
</Card.Section>
<Stack gap="md" pt="md">
<MessageList
messages={
generationState.isGeneratingResponse
? [...messages, { role: "assistant", content: streamedResponse }]
: messages
}
onEditMessage={handleEditMessage}
onRegenerate={handleRegenerateResponse}
isGenerating={generationState.isGeneratingResponse}
/>
<ChatInputArea onKeyDown={handleKeyDown} handleSend={handleSend} />
</Stack>
</Card>
);
}