You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

933 lines
30 KiB
TypeScript

import {
ActionIcon,
Box,
Group,
HoverCard,
JsonInput,
List,
ScrollArea,
Stack,
Tabs,
Text,
Textarea,
TextInput,
Transition,
useMantineTheme,
} from "@mantine/core";
import { memo, useEffect, useState } from "react";
import {
defaultParameters,
defaultSystemPrompt,
useStore,
} from "../../../state";
import { usePageContext } from "vike-react/usePageContext";
import { useData } from "vike-react/useData";
import type { Data } from "./+data";
import type {
CommittedMessage,
DraftMessage,
OtherParameters,
SendMessageStatus,
SendMessageStatusUI,
} from "../../../types";
import Markdown from "react-markdown";
import {
IconTrash,
IconEdit,
IconCheck,
IconX,
IconLoaderQuarter,
} from "@tabler/icons-react";
import { useTRPC, useTRPCClient } from "../../trpc-client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Conversation } from "@database/common";
export default function ChatPage() {
const pageContext = usePageContext();
const conversationId = pageContext.routeParams.id;
const {
conversation,
// messages: initialMessages,
// facts: initialFacts,
// factTriggers: initialFactTriggers,
} = useData<Data>();
const conversationTitle = conversation?.title;
const message = useStore((state) => state.message);
const systemPrompt = useStore((state) => state.systemPrompt);
const parameters = useStore((state) => state.parameters);
const loading = useStore((state) => state.loading);
const setMessage = useStore((state) => state.setMessage);
const setSystemPrompt = useStore((state) => state.setSystemPrompt);
const setParameters = useStore((state) => state.setParameters);
const setLoading = useStore((state) => state.setLoading);
const trpc = useTRPC();
const trpcClient = useTRPCClient();
const queryClient = useQueryClient();
const messagesResult = useQuery(
trpc.chat.messages.fetchByConversationId.queryOptions({
conversationId,
})
);
const messages: Array<CommittedMessage> | undefined =
messagesResult.data?.map((m) => ({
...m,
parts: m.parts.filter((p) => p.type === "text"),
})) || [];
const facts = useQuery(
trpc.chat.facts.fetchByConversationId.queryOptions({
conversationId,
})
);
const factTriggers = useQuery(
trpc.chat.factTriggers.fetchByConversationId.queryOptions({
conversationId,
})
);
const deleteFact = useMutation(
trpc.chat.facts.deleteOne.mutationOptions({
onMutate: async ({ factId: factIdToDelete }) => {
/** Cancel affected queries that may be in-flight: */
await queryClient.cancelQueries({
queryKey: trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
});
/** Optimistically update the affected queries in react-query's cache: */
const previousFacts = await queryClient.getQueryData(
trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
})
);
if (!previousFacts) {
return {
previousFacts: [],
newFacts: [],
};
}
const newFacts = previousFacts.filter((f) => f.id !== factIdToDelete);
queryClient.setQueryData(
trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
newFacts
);
return { previousFacts, newFacts };
},
onSettled: async (data, variables, context) => {
await queryClient.invalidateQueries({
queryKey: trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
});
},
onError: async (error, variables, context) => {
console.error(error);
if (!context) return;
queryClient.setQueryData(
trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
context.previousFacts
);
},
})
);
const updateFact = useMutation(
trpc.chat.facts.update.mutationOptions({
onMutate: async ({ factId, content }) => {
/** Cancel affected queries that may be in-flight: */
await queryClient.cancelQueries({
queryKey: trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
});
/** Optimistically update the affected queries in react-query's cache: */
const previousFacts = await queryClient.getQueryData(
trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
})
);
if (!previousFacts) {
return {
previousFacts: [],
newFacts: [],
};
}
const newFacts = previousFacts.map((f) =>
f.id === factId ? { ...f, content } : f
);
queryClient.setQueryData(
trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
newFacts
);
return { previousFacts, newFacts };
},
onSettled: async (data, variables, context) => {
await queryClient.invalidateQueries({
queryKey: trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
});
},
onError: async (error, variables, context) => {
console.error(error);
if (!context) return;
queryClient.setQueryData(
trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
context.previousFacts
);
},
})
);
const deleteFactTrigger = useMutation(
trpc.chat.factTriggers.deleteOne.mutationOptions({
onMutate: async ({ factTriggerId: factTriggerIdToDelete }) => {
/** Cancel affected queries that may be in-flight: */
await queryClient.cancelQueries({
queryKey: trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
}),
});
/** Optimistically update the affected queries in react-query's cache: */
const previousFactTriggers = await queryClient.getQueryData(
trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
})
);
if (!previousFactTriggers) {
return {
previousFactTriggers: [],
newFactTriggers: [],
};
}
const newFactTriggers = previousFactTriggers.filter(
(ft) => ft.id !== factTriggerIdToDelete
);
queryClient.setQueryData(
trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
}),
newFactTriggers
);
return { previousFactTriggers, newFactTriggers };
},
onSettled: async (data, variables, context) => {
await queryClient.invalidateQueries({
queryKey: trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
}),
});
},
onError: async (error, variables, context) => {
console.error(error);
if (!context) return;
queryClient.setQueryData(
trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
}),
context.previousFactTriggers
);
},
})
);
const updateFactTrigger = useMutation(
trpc.chat.factTriggers.update.mutationOptions({
onMutate: async ({ factTriggerId, content }) => {
/** Cancel affected queries that may be in-flight: */
await queryClient.cancelQueries({
queryKey: trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
}),
});
/** Optimistically update the affected queries in react-query's cache: */
const previousFactTriggers = await queryClient.getQueryData(
trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
})
);
if (!previousFactTriggers) {
return {
previousFactTriggers: [],
newFactTriggers: [],
};
}
const newFactTriggers = previousFactTriggers.map((ft) =>
ft.id === factTriggerId ? { ...ft, content } : ft
);
queryClient.setQueryData(
trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
}),
newFactTriggers
);
return { previousFactTriggers, newFactTriggers };
},
onSettled: async (data, variables, context) => {
await queryClient.invalidateQueries({
queryKey: trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
}),
});
},
onError: async (error, variables, context) => {
console.error(error);
if (!context) return;
queryClient.setQueryData(
trpc.chat.factTriggers.fetchByConversationId.queryKey({
conversationId,
}),
context.previousFactTriggers
);
},
})
);
const updateConversationTitle = useMutation(
trpc.chat.conversations.updateTitle.mutationOptions({
onMutate: async ({ id, title }) => {
/** Cancel affected queries that may be in-flight: */
await queryClient.cancelQueries({
queryKey: trpc.chat.conversations.fetchAll.queryKey(),
});
/** Optimistically update the affected queries in react-query's cache: */
const previousConversations = await queryClient.getQueryData(
trpc.chat.conversations.fetchAll.queryKey()
);
if (!previousConversations) {
return {
previousConversations: [],
newConversations: null,
};
}
const newConversations: Array<Conversation> = [
...previousConversations,
{
...conversation,
title,
} as Conversation,
];
queryClient.setQueryData<Array<Conversation>>(
trpc.chat.conversations.fetchAll.queryKey(),
newConversations
);
return { previousConversations, newConversations };
},
onSettled: async (data, variables, context) => {
await queryClient.invalidateQueries({
queryKey: trpc.chat.conversations.fetchOne.queryKey({
id: conversationId,
}),
});
await queryClient.invalidateQueries({
queryKey: trpc.chat.conversations.fetchAll.queryKey(),
});
},
onError: async (error, variables, context) => {
console.error(error);
if (!context) return;
queryClient.setQueryData(
trpc.chat.conversations.fetchAll.queryKey(),
context.previousConversations
);
},
})
);
// Get state from Zustand store
const sendMessageStatus = useStore((state) => state.sendMessageStatus);
const isSendingMessage = useStore((state) => state.isSendingMessage);
const setSendMessageStatus = useStore((state) => state.setSendMessageStatus);
const setIsSendingMessage = useStore((state) => state.setIsSendingMessage);
const theme = useMantineTheme();
// Function to send message using subscription
const sendSubscriptionMessage = async ({
conversationId,
messages,
systemPrompt,
parameters,
}: {
conversationId: string;
messages: Array<DraftMessage | CommittedMessage>;
systemPrompt: string;
parameters: OtherParameters;
}) => {
setIsSendingMessage(true);
setSendMessageStatus(null);
try {
// Create an abort controller for the subscription
const abortController = new AbortController();
// Start the subscription
const subscription = trpcClient.chat.sendMessage.subscribe(
{
conversationId,
messages,
systemPrompt,
parameters,
},
{
signal: abortController.signal,
onData: (data) => {
setSendMessageStatus(data as SendMessageStatus);
// If we've completed, update the UI and invalidate queries
if (data.status === "completed") {
setIsSendingMessage(false);
// Invalidate queries to refresh the data
queryClient.invalidateQueries({
queryKey: trpc.chat.messages.fetchByConversationId.queryKey({
conversationId,
}),
});
queryClient.invalidateQueries({
queryKey: trpc.chat.facts.fetchByConversationId.queryKey({
conversationId,
}),
});
queryClient.invalidateQueries({
queryKey: trpc.chat.factTriggers.fetchByConversationId.queryKey(
{
conversationId,
}
),
});
} else {
setSendMessageStatus(data);
}
},
onError: (error) => {
console.error("Subscription error:", error);
setIsSendingMessage(false);
setSendMessageStatus({
status: "error" as const,
message: "An error occurred while sending the message",
});
},
}
);
// Return a function to unsubscribe if needed
return () => {
abortController.abort();
subscription.unsubscribe();
};
} catch (error) {
console.error("Failed to start subscription:", error);
setIsSendingMessage(false);
setSendMessageStatus({
status: "error" as const,
message: "Failed to start message sending process",
});
}
};
// State for editing facts
const [editingFactId, setEditingFactId] = useState<string | null>(null);
const [editingFactContent, setEditingFactContent] = useState("");
// State for editing fact triggers
const [editingFactTriggerId, setEditingFactTriggerId] = useState<
string | null
>(null);
const [editingFactTriggerContent, setEditingFactTriggerContent] =
useState("");
// Handle clicking outside to cancel editing
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (editingFactId && event.target instanceof Element) {
const editingElement = event.target.closest(".editing-fact");
if (!editingElement) {
setEditingFactId(null);
}
}
if (editingFactTriggerId && event.target instanceof Element) {
const editingElement = event.target.closest(".editing-fact-trigger");
if (!editingElement) {
setEditingFactTriggerId(null);
}
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [editingFactId, editingFactTriggerId]);
async function handleDeleteFact(factId: string) {
await deleteFact.mutateAsync({ factId });
}
async function handleUpdateFact(factId: string, content: string) {
await updateFact.mutateAsync({ factId, content });
}
async function handleDeleteFactTrigger(factTriggerId: string) {
await deleteFactTrigger.mutateAsync({ factTriggerId });
}
async function handleUpdateFactTrigger(
factTriggerId: string,
content: string
) {
await updateFactTrigger.mutateAsync({ factTriggerId, content });
}
return (
<Box h="100%" w="100%" style={{ display: "flex", flexDirection: "column" }}>
<Group justify="flex-start" gap={"sm"} w="100%">
<TextInput
inputSize={Math.max(conversationTitle?.length || 0, 22).toString()}
description={`Conversation #${conversationId}`}
defaultValue={conversationTitle || ""}
// onChange={(e) => {
// setConversationTitle(e.target.value);
// }}
onKeyUp={(e) => {
if (e.key === "Enter") {
e.preventDefault();
// updateConversationTitle.mutateAsync({
// id: conversationId,
// title: e.currentTarget.value,
// });
e.currentTarget.blur();
}
}}
onBlur={(e) => {
updateConversationTitle.mutateAsync({
id: conversationId,
title: e.target.value,
});
}}
variant="unstyled"
styles={{
input: {
// backgroundColor: "transparent",
// border: "none",
// padding: 0,
// margin: 0,
fontFamily: theme.headings.fontFamily,
fontSize: theme.fontSizes.lg,
lineHeight: theme.lineHeights["4xl"],
},
wrapper: {
marginTop: 0,
},
}}
/>
{isSendingMessage && <IconLoaderQuarter size={16} stroke={1.5} />}
{sendMessageStatus && (
<StatusMessage sendMessageStatus={sendMessageStatus} />
)}
</Group>
<Tabs
defaultValue="message"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
overflow: "hidden",
}}
>
<Tabs.List>
<Tabs.Tab value="message">Message</Tabs.Tab>
<Tabs.Tab value="system-prompt">System Prompt</Tabs.Tab>
<Tabs.Tab value="parameters">Parameters</Tabs.Tab>
<Tabs.Tab value="facts">Facts</Tabs.Tab>
<Tabs.Tab value="fact-triggers">Fact Triggers</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="message" style={{ flex: 1, overflow: "hidden" }}>
<Stack gap="md" justify="space-between" h="100%">
<ScrollArea scrollbars="y" style={{ flex: 1 }}>
<Messages />
</ScrollArea>
<Textarea
resize="vertical"
placeholder="Type your message here..."
value={message}
disabled={loading}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
setLoading(true);
await sendSubscriptionMessage({
conversationId,
messages: [
...(messages || []),
{
role: "user" as const,
parts: [{ type: "text", text: message }],
} as DraftMessage,
],
systemPrompt,
parameters,
});
setMessage("");
setLoading(false);
}
}}
/>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="system-prompt">
<Textarea
resize="vertical"
placeholder={defaultSystemPrompt}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
/>
</Tabs.Panel>
<Tabs.Panel value="parameters">
<JsonInput
resize="vertical"
formatOnBlur
placeholder={JSON.stringify(defaultParameters)}
value={JSON.stringify(parameters)}
onChange={(value) => setParameters(JSON.parse(value))}
/>
</Tabs.Panel>
<Tabs.Panel value="facts">
<List>
{facts.data?.map((fact) => (
<List.Item key={fact.id}>
{editingFactId === fact.id ? (
<Group wrap="nowrap" className="editing-fact">
<Textarea
value={editingFactContent}
onChange={(e) => setEditingFactContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdateFact(fact.id, editingFactContent);
setEditingFactId(null);
} else if (e.key === "Escape") {
setEditingFactId(null);
}
}}
autoFocus
style={{ flex: 1 }}
/>
<ActionIcon
onClick={() => {
handleUpdateFact(fact.id, editingFactContent);
setEditingFactId(null);
}}
>
<IconCheck size={16} />
</ActionIcon>
<ActionIcon onClick={() => setEditingFactId(null)}>
<IconX size={16} />
</ActionIcon>
</Group>
) : (
<Group wrap="nowrap">
<span style={{ flex: 1 }}>{fact.content}</span>
<ActionIcon
onClick={() => {
setEditingFactId(fact.id);
setEditingFactContent(fact.content);
}}
>
<IconEdit size={16} />
</ActionIcon>
<IconTrash
size={16}
stroke={1.5}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteFact(fact.id);
}}
/>
</Group>
)}
</List.Item>
))}
</List>
</Tabs.Panel>
<Tabs.Panel value="fact-triggers">
<List>
{factTriggers.data?.map((factTrigger) => (
<List.Item key={factTrigger.id}>
{editingFactTriggerId === factTrigger.id ? (
<Group wrap="nowrap" className="editing-fact-trigger">
<Textarea
value={editingFactTriggerContent}
onChange={(e) =>
setEditingFactTriggerContent(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdateFactTrigger(
factTrigger.id,
editingFactTriggerContent
);
setEditingFactTriggerId(null);
} else if (e.key === "Escape") {
setEditingFactTriggerId(null);
}
}}
autoFocus
style={{ flex: 1 }}
/>
<ActionIcon
onClick={() => {
handleUpdateFactTrigger(
factTrigger.id,
editingFactTriggerContent
);
setEditingFactTriggerId(null);
}}
>
<IconCheck size={16} />
</ActionIcon>
<ActionIcon onClick={() => setEditingFactTriggerId(null)}>
<IconX size={16} />
</ActionIcon>
</Group>
) : (
<Group wrap="nowrap">
<span style={{ flex: 1 }}>{factTrigger.content}</span>
<ActionIcon
onClick={() => {
setEditingFactTriggerId(factTrigger.id);
setEditingFactTriggerContent(factTrigger.content);
}}
>
<IconEdit size={16} />
</ActionIcon>
<IconTrash
size={16}
stroke={1.5}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteFactTrigger(factTrigger.id);
}}
/>
</Group>
)}
</List.Item>
))}
</List>
</Tabs.Panel>
</Tabs>
</Box>
);
}
function Messages() {
const theme = useMantineTheme();
const queryClient = useQueryClient();
const trpc = useTRPC();
const pageContext = usePageContext();
const conversationId = pageContext.routeParams.id;
const messagesResult = useQuery(
trpc.chat.messages.fetchByConversationId.queryOptions({
conversationId,
})
);
const messages: Array<DraftMessage | CommittedMessage> | undefined =
messagesResult.data?.map((m) => ({
...m,
parts: m.parts.filter((p) => p.type === "text"),
})) || [];
const deleteMessage = useMutation(
trpc.chat.messages.deleteOne.mutationOptions({
onMutate: async ({ id: messageIdToDelete }) => {
/** Cancel affected queries that may be in-flight: */
await queryClient.cancelQueries({
queryKey: trpc.chat.messages.fetchByConversationId.queryKey({
conversationId,
}),
});
/** Optimistically update the affected queries in react-query's cache: */
const previousMessages = await queryClient.getQueryData<
Array<CommittedMessage>
>(
trpc.chat.messages.fetchByConversationId.queryKey({
conversationId,
})
);
if (!previousMessages) {
return {
previousMessages: [],
newMessages: [],
};
}
const newMessages = previousMessages.filter(
(m: CommittedMessage) => m.id !== messageIdToDelete
);
queryClient.setQueryData<Array<CommittedMessage>>(
trpc.chat.messages.fetchByConversationId.queryKey({
conversationId,
}),
newMessages
);
return { previousMessages, newMessages };
},
onSettled: async (data, variables, context) => {
await queryClient.invalidateQueries({
queryKey: trpc.chat.messages.fetchByConversationId.queryKey({
conversationId,
}),
});
},
onError: async (error, variables, context) => {
console.error(error);
if (!context) return;
queryClient.setQueryData(
trpc.chat.messages.fetchByConversationId.queryKey({
conversationId,
}),
context.previousMessages
);
},
})
);
async function handleDeleteMessage(message: DraftMessage | CommittedMessage) {
// If the message does not have an id, do nothing
if (!("id" in message) || !message.id) {
return;
}
// Delete the message from the server using the mutation
try {
await deleteMessage.mutateAsync({ id: message.id });
} catch (error) {
console.error("Failed to delete message:", error);
}
}
return (
<Stack gap="md" justify="flex-start">
{messages.map((message, index) => (
<Group
key={"id" in message ? message.id : index}
justify={message.role === "user" ? "flex-end" : "flex-start"}
>
<HoverCard
shadow="md"
position={message.role === "user" ? "left" : "right"}
>
<HoverCard.Target>
<ScrollArea
scrollbars="x"
w="75%"
p="md"
bdrs="md"
bg={
message.role === "user"
? theme.colors.gray[2]
: theme.colors.blue[2]
}
>
<Markdown>
{message.parts
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")}
</Markdown>
</ScrollArea>
</HoverCard.Target>
<HoverCard.Dropdown>
<ActionIcon.Group>
<ActionIcon
size="lg"
variant="filled"
color="red"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteMessage(message);
}}
>
<IconTrash size={16} stroke={1.5} />
</ActionIcon>
</ActionIcon.Group>
</HoverCard.Dropdown>
</HoverCard>
{"runningSummary" in message && message.runningSummary && (
<Box w="75%" bd="dotted" p="md" bdrs="md">
<div>
<strong>Running Summary:</strong>
<Markdown>{message.runningSummary}</Markdown>
</div>
</Box>
)}
</Group>
))}
</Stack>
);
}
const StatusMessage = memo(
({ sendMessageStatus }: { sendMessageStatus: SendMessageStatusUI }) => {
const [displayMessage, setDisplayMessage] = useState(sendMessageStatus);
const [isVisible, setIsVisible] = useState(sendMessageStatus !== null);
useEffect(() => {
if (sendMessageStatus === null) {
setIsVisible(false);
setTimeout(() => setDisplayMessage(null), 250);
} else if (displayMessage === null) {
setDisplayMessage(sendMessageStatus);
setIsVisible(true);
} else if (displayMessage.message !== sendMessageStatus.message) {
setIsVisible(false);
setTimeout(() => {
setDisplayMessage(sendMessageStatus);
setIsVisible(true);
}, 250);
}
}, [sendMessageStatus, displayMessage]);
return (
<div style={{ position: "relative" }}>
{/* This is a hack to make this component take up the space it would take up once the transition is complete. Useful for when this component is in a flexbox with a particular alignment/justification, which we want to be calculated against its eventual size. */}
<span style={{ visibility: "hidden" }}>{displayMessage?.message}</span>
<Transition
transition="pop"
duration={500}
mounted={isVisible && displayMessage !== null}
>
{(styles) => (
<span style={{ ...styles, position: "absolute", top: 0, left: 0 }}>
{displayMessage?.message}
</span>
)}
</Transition>
</div>
);
}
);