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.
909 lines
29 KiB
TypeScript
909 lines
29 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,
|
|
} 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 { nanoid } from "nanoid";
|
|
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(
|
|
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);
|
|
|
|
// 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",
|
|
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",
|
|
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>
|
|
<Group justify="flex-start" gap={"sm"}>
|
|
<TextInput
|
|
inputSize="50"
|
|
description={`Conversation #${conversationId}`}
|
|
defaultValue={conversationTitle || ""}
|
|
// onChange={(e) => {
|
|
// setConversationTitle(e.target.value);
|
|
// }}
|
|
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">
|
|
<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">
|
|
<Messages />
|
|
<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);
|
|
}
|
|
}}
|
|
/>
|
|
</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(
|
|
trpc.chat.messages.fetchByConversationId.queryKey({
|
|
conversationId,
|
|
})
|
|
);
|
|
if (!previousMessages) {
|
|
return {
|
|
previousMessages: [],
|
|
newMessages: [],
|
|
};
|
|
}
|
|
const newMessages = previousMessages.filter(
|
|
(m: CommittedMessage) => m.id !== messageIdToDelete
|
|
);
|
|
queryClient.setQueryData(
|
|
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: SendMessageStatus | null }) => {
|
|
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>
|
|
);
|
|
}
|
|
);
|