addeed chathistory functionality

This commit is contained in:
Dhravya 2024-06-22 17:43:16 -05:00
parent 074ea24565
commit a2f8a27e77
15 changed files with 523 additions and 119 deletions

View file

@ -0,0 +1,35 @@
import { getFullChatThread } from "@/app/actions/fetchers";
import { chatSearchParamsCache } from "@/lib/searchParams";
import ChatWindow from "../chatWindow";
async function Page({
params,
searchParams,
}: {
params: { chatid: string };
searchParams: Record<string, string | string[] | undefined>;
}) {
const { firstTime, q, spaces } = chatSearchParamsCache.parse(searchParams);
const chat = await getFullChatThread(params.chatid);
console.log(chat);
if (!chat.success || !chat.data) {
// TODO: handle this error
return <div>Chat not found</div>;
}
console.log(chat.data);
return (
<ChatWindow
q={q}
spaces={spaces}
initialChat={chat.data.length > 0 ? chat.data : undefined}
threadId={params.chatid}
/>
);
}
export default Page;

View file

@ -23,16 +23,12 @@ import { codeLanguageSubset } from "@/lib/constants";
import { z } from "zod";
import { toast } from "sonner";
import Link from "next/link";
import { createChatObject } from "@/app/actions/doers";
function ChatWindow({
q,
spaces,
}: {
q: string;
spaces: { id: string; name: string }[];
}) {
const [layout, setLayout] = useState<"chat" | "initial">("initial");
const [chatHistory, setChatHistory] = useState<ChatHistory[]>([
initialChat = [
{
question: q,
answer: {
@ -40,7 +36,18 @@ function ChatWindow({
sources: [],
},
},
]);
],
threadId,
}: {
q: string;
spaces: { id: string; name: string }[];
initialChat?: ChatHistory[];
threadId: string;
}) {
const [layout, setLayout] = useState<"chat" | "initial">(
initialChat.length > 1 ? "chat" : "initial",
);
const [chatHistory, setChatHistory] = useState<ChatHistory[]>(initialChat);
const [isAutoScroll, setIsAutoScroll] = useState(true);
const removeJustificationFromText = (text: string) => {
@ -61,7 +68,7 @@ function ChatWindow({
const getAnswer = async (query: string, spaces: string[]) => {
const sourcesFetch = await fetch(
`/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true`,
`/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true&threadId=${threadId}`,
{
method: "POST",
body: JSON.stringify({ chatHistory }),
@ -84,74 +91,108 @@ function ChatWindow({
toast.error("Something went wrong while getting the sources");
return;
}
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: "smooth",
});
setChatHistory((prevChatHistory) => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: "smooth",
// Assuming this is part of a larger function within a React component
const updateChatHistoryAndFetch = async () => {
// Step 1: Update chat history with the assistant's response
await new Promise((resolve) => {
setChatHistory((prevChatHistory) => {
const newChatHistory = [...prevChatHistory];
const lastAnswer = newChatHistory[newChatHistory.length - 1];
if (!lastAnswer) {
resolve(undefined);
return prevChatHistory;
}
const filteredSourceUrls = new Set(
sourcesParsed.data.metadata.map((source) => source.url),
);
const uniqueSources = sourcesParsed.data.metadata.filter((source) => {
if (filteredSourceUrls.has(source.url)) {
filteredSourceUrls.delete(source.url);
return true;
}
return false;
});
lastAnswer.answer.sources = uniqueSources.map((source) => ({
title: source.title ?? "Untitled",
type: source.type ?? "page",
source: source.url ?? "https://supermemory.ai",
content: source.description ?? "No content available",
numChunks: sourcesParsed.data.metadata.filter(
(f) => f.url === source.url,
).length,
}));
resolve(newChatHistory);
return newChatHistory;
});
});
const newChatHistory = [...prevChatHistory];
const lastAnswer = newChatHistory[newChatHistory.length - 1];
if (!lastAnswer) return prevChatHistory;
const filteredSourceUrls = new Set(
sourcesParsed.data.metadata.map((source) => source.url),
// Step 2: Fetch data from the API
const resp = await fetch(
`/api/chat?q=${query}&spaces=${spaces}&threadId=${threadId}`,
{
method: "POST",
body: JSON.stringify({ chatHistory }),
},
);
const uniqueSources = sourcesParsed.data.metadata.filter((source) => {
if (filteredSourceUrls.has(source.url)) {
filteredSourceUrls.delete(source.url);
return true;
}
return false;
});
lastAnswer.answer.sources = uniqueSources.map((source) => ({
title: source.title ?? "Untitled",
type: source.type ?? "page",
source: source.url ?? "https://supermemory.ai",
content: source.description ?? "No content available",
numChunks: sourcesParsed.data.metadata.filter(
(f) => f.url === source.url,
).length,
}));
return newChatHistory;
});
const resp = await fetch(`/api/chat?q=${query}&spaces=${spaces}`, {
method: "POST",
body: JSON.stringify({ chatHistory }),
});
const reader = resp.body?.getReader();
let done = false;
while (!done && reader) {
const { value, done: d } = await reader.read();
done = d;
setChatHistory((prevChatHistory) => {
const newChatHistory = [...prevChatHistory];
const lastAnswer = newChatHistory[newChatHistory.length - 1];
if (!lastAnswer) return prevChatHistory;
const txt = new TextDecoder().decode(value);
if (isAutoScroll) {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: "smooth",
// Step 3: Read the response stream and update the chat history
const reader = resp.body?.getReader();
let done = false;
while (!done && reader) {
const { value, done: d } = await reader.read();
if (d) {
console.log(chatHistory);
setChatHistory((prevChatHistory) => {
console.log(prevChatHistory);
createChatObject(threadId, prevChatHistory);
return prevChatHistory;
});
}
done = d;
lastAnswer.answer.parts.push({ text: txt });
return newChatHistory;
});
}
const txt = new TextDecoder().decode(value);
setChatHistory((prevChatHistory) => {
const newChatHistory = [...prevChatHistory];
const lastAnswer = newChatHistory[newChatHistory.length - 1];
if (!lastAnswer) return prevChatHistory;
if (isAutoScroll) {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: "smooth",
});
}
lastAnswer.answer.parts.push({ text: txt });
return newChatHistory;
});
}
};
updateChatHistoryAndFetch();
};
useEffect(() => {
if (q.trim().length > 0) {
if (q.trim().length > 0 || chatHistory.length > 0) {
setLayout("chat");
getAnswer(
q,
spaces.map((s) => s.id),
);
const lastChat = chatHistory.length > 0 ? chatHistory.length - 1 : 0;
const startGenerating = chatHistory[lastChat]?.answer.parts[0]?.text
? false
: true;
if (startGenerating) {
getAnswer(
q,
spaces.map((s) => `${s}`),
);
}
} else {
router.push("/home");
}

View file

@ -12,7 +12,7 @@ function Page({
console.log(spaces);
return <ChatWindow q={q} spaces={[]} />;
return <ChatWindow q={q} spaces={spaces} threadId={""} />;
}
export default Page;

View file

@ -5,6 +5,7 @@ import QueryInput from "./queryinput";
import { homeSearchParamsCache } from "@/lib/searchParams";
import { getSpaces } from "@/app/actions/fetchers";
import { useRouter } from "next/navigation";
import { createChatThread } from "@/app/actions/doers";
function Page({
searchParams,
@ -12,7 +13,8 @@ function Page({
searchParams: Record<string, string | string[] | undefined>;
}) {
// TODO: use this to show a welcome page/modal
const { firstTime } = homeSearchParamsCache.parse(searchParams);
// const { firstTime } = homeSearchParamsCache.parse(searchParams);
const { push } = useRouter();
const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]);
@ -20,13 +22,12 @@ function Page({
getSpaces().then((res) => {
if (res.success && res.data) {
setSpaces(res.data);
return;
}
// TODO: HANDLE ERROR
});
}, []);
const { push } = useRouter();
return (
<div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col">
{/* all content goes here */}
@ -34,13 +35,12 @@ function Page({
<div className="w-full h-96">
<QueryInput
handleSubmit={(q, spaces) => {
const newQ =
"/chat?q=" +
encodeURI(q) +
(spaces ? "&spaces=" + JSON.stringify(spaces) : "");
handleSubmit={async (q, spaces) => {
const threadid = await createChatThread(q);
push(newQ);
push(
`/chat/${threadid.data}?spaces=${JSON.stringify(spaces)}&q=${q}`,
);
}}
initialSpaces={spaces}
/>

View file

@ -2,7 +2,13 @@
import { revalidatePath } from "next/cache";
import { db } from "../../server/db";
import { contentToSpace, space, storedContent } from "../../server/db/schema";
import {
chatHistory,
chatThreads,
contentToSpace,
space,
storedContent,
} from "../../server/db/schema";
import { ServerActionReturnType } from "./types";
import { auth } from "../../server/auth";
import { Tweet } from "react-tweet/api";
@ -10,6 +16,7 @@ import { getMetaData } from "@/lib/get-metadata";
import { and, eq, inArray, sql } from "drizzle-orm";
import { LIMITS } from "@/lib/constants";
import { z } from "zod";
import { ChatHistory } from "@repo/shared-types";
export const createSpace = async (
input: string | FormData,
@ -266,3 +273,74 @@ export const createMemory = async (input: {
};
}
};
export const createChatThread = async (
firstMessage: string,
): ServerActionReturnType<string> => {
const data = await auth();
if (!data || !data.user || !data.user.id) {
return { error: "Not authenticated", success: false };
}
const thread = await db
.insert(chatThreads)
.values({
firstMessage,
userId: data.user.id,
})
.returning({ id: chatThreads.id })
.execute();
console.log(thread);
if (!thread[0]) {
return {
success: false,
error: "Failed to create chat thread",
};
}
return { success: true, data: thread[0].id };
};
export const createChatObject = async (
threadId: string,
chatHistorySoFar: ChatHistory[],
): ServerActionReturnType<boolean> => {
const data = await auth();
if (!data || !data.user || !data.user.id) {
return { error: "Not authenticated", success: false };
}
const lastChat = chatHistorySoFar[chatHistorySoFar.length - 1];
if (!lastChat) {
return {
success: false,
data: false,
error: "No chat object found",
};
}
console.log("sources: ", lastChat.answer.sources);
const saved = await db.insert(chatHistory).values({
question: lastChat.question,
answer: lastChat.answer.parts.map((part) => part.text).join(""),
answerSources: JSON.stringify(lastChat.answer.sources),
threadId,
});
if (!saved) {
return {
success: false,
data: false,
error: "Failed to save chat object",
};
}
return {
success: true,
data: true,
};
};

View file

@ -1,8 +1,10 @@
"use server";
import { eq, inArray, not, sql } from "drizzle-orm";
import { and, asc, eq, inArray, not, sql } from "drizzle-orm";
import { db } from "../../server/db";
import {
chatHistory,
chatThreads,
Content,
contentToSpace,
storedContent,
@ -10,6 +12,8 @@ import {
} from "../../server/db/schema";
import { ServerActionReturnType, Space } from "./types";
import { auth } from "../../server/auth";
import { ChatHistory, SourceZod } from "@repo/shared-types";
import { z } from "zod";
export const getSpaces = async (): ServerActionReturnType<Space[]> => {
const data = await auth();
@ -103,22 +107,27 @@ export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{
// console.log(contentCountBySpace);
// get a count with space mappings like spaceID: count (number of memories in that space)
const contentCountBySpace = await db
.select({
spaceId: contentToSpace.spaceId,
count: sql<number>`count(*)`.mapWith(Number),
})
.from(contentToSpace)
.where(
inArray(
contentToSpace.spaceId,
spacesWithoutUser.map((space) => space.id),
),
)
.groupBy(contentToSpace.spaceId)
.execute();
console.log(contentCountBySpace);
const len = spacesWithoutUser.map((space) => space.id).length;
if (len > 0) {
const contentCountBySpace = await db
.select({
spaceId: contentToSpace.spaceId,
count: sql<number>`count(*)`.mapWith(Number),
})
.from(contentToSpace)
.where(
inArray(
contentToSpace.spaceId,
spacesWithoutUser.map((space) => space.id),
),
)
.groupBy(contentToSpace.spaceId)
.execute();
console.log(contentCountBySpace);
}
const contentNotInAnySpace = await db
.select()
@ -140,3 +149,64 @@ export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{
data: { spaces: spacesWithoutUser, memories: contentNotInAnySpace },
};
};
export const getFullChatThread = async (
threadId: string,
): ServerActionReturnType<ChatHistory[]> => {
const data = await auth();
if (!data || !data.user || !data.user.id) {
return { error: "Not authenticated", success: false };
}
const thread = await db.query.chatThreads.findFirst({
where: and(
eq(chatThreads.id, threadId),
eq(chatThreads.userId, data.user.id),
),
});
if (!thread) {
return { error: "Thread not found", success: false };
}
const allChatsInThisThread = await db.query.chatHistory
.findMany({
where: and(eq(chatHistory.threadId, threadId)),
orderBy: asc(chatHistory.id),
})
.execute();
const accumulatedChatHistory: ChatHistory[] = allChatsInThisThread.map(
(chat) => {
console.log("answer sources", chat.answerSources);
const sourceCheck = z
.array(SourceZod)
.safeParse(JSON.parse(chat.answerSources ?? "[]"));
if (!sourceCheck.success || !sourceCheck.data) {
console.error("sourceCheck.error", sourceCheck.error);
throw new Error("Invalid source data");
}
const sources = sourceCheck.data;
return {
question: chat.question,
answer: {
parts: [
{
text: chat.answer ?? undefined,
},
],
sources: sources ?? [],
},
};
},
);
return {
success: true,
data: accumulatedChatHistory,
};
};

View file

@ -1,9 +1,5 @@
import { type NextRequest } from "next/server";
import {
ChatHistory,
ChatHistoryZod,
convertChatHistoryList,
} from "@repo/shared-types";
import { ChatHistoryZod, convertChatHistoryList } from "@repo/shared-types";
import { ensureAuth } from "../ensureAuth";
import { z } from "zod";
@ -49,6 +45,8 @@ export async function POST(req: NextRequest) {
);
}
console.log("validated.data.chatHistory", validated.data.chatHistory);
const modelCompatible = await convertChatHistoryList(
validated.data.chatHistory,
);

View file

@ -1,7 +1,7 @@
import { type Config } from "drizzle-kit";
export default {
schema: "./app/helpers/server/db/schema.ts",
schema: "./server/db/schema.ts",
dialect: "sqlite",
driver: "d1",
dbCredentials: {

View file

@ -16,11 +16,19 @@ export const chatSearchParamsCache = createSearchParamsCache({
firstTime: parseAsBoolean.withDefault(false),
q: parseAsString.withDefault(""),
spaces: parseAsArrayOf(
parseAsJson(() =>
z.object({
id: z.string(),
name: z.string(),
}),
),
parseAsJson((c) => {
const valid = z
.object({
id: z.string(),
name: z.string(),
})
.safeParse(c);
if (!valid.success) {
return null;
}
return valid.data;
}),
).withDefault([]),
});

View file

@ -27,6 +27,23 @@ CREATE TABLE `authenticator` (
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `chatHistory` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`threadId` text NOT NULL,
`question` text NOT NULL,
`answerParts` text,
`answerSources` text,
`answerJustification` text,
FOREIGN KEY (`threadId`) REFERENCES `chatThread`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `chatThread` (
`id` text PRIMARY KEY NOT NULL,
`firstMessage` text NOT NULL,
`userId` text NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `contentToSpace` (
`contentId` integer NOT NULL,
`spaceId` integer NOT NULL,
@ -60,7 +77,7 @@ CREATE TABLE `storedContent` (
`ogImage` text(255),
`type` text DEFAULT 'page',
`image` text(255),
`user` integer,
`user` text,
FOREIGN KEY (`user`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
@ -80,6 +97,8 @@ CREATE TABLE `verificationToken` (
);
--> statement-breakpoint
CREATE UNIQUE INDEX `authenticator_credentialID_unique` ON `authenticator` (`credentialID`);--> statement-breakpoint
CREATE INDEX `chatHistory_thread_idx` ON `chatHistory` (`threadId`);--> statement-breakpoint
CREATE INDEX `chatThread_user_idx` ON `chatThread` (`userId`);--> statement-breakpoint
CREATE UNIQUE INDEX `space_name_unique` ON `space` (`name`);--> statement-breakpoint
CREATE INDEX `spaces_name_idx` ON `space` (`name`);--> statement-breakpoint
CREATE INDEX `spaces_user_idx` ON `space` (`user`);--> statement-breakpoint

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "4a568d9b-a0e6-44ed-946b-694e34b063f3",
"id": "349eea0d-f26e-4579-9c65-3982816b0c6c",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@ -191,6 +191,119 @@
},
"uniqueConstraints": {}
},
"chatHistory": {
"name": "chatHistory",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"threadId": {
"name": "threadId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"question": {
"name": "question",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"answerParts": {
"name": "answerParts",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"answerSources": {
"name": "answerSources",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"answerJustification": {
"name": "answerJustification",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"chatHistory_thread_idx": {
"name": "chatHistory_thread_idx",
"columns": ["threadId"],
"isUnique": false
}
},
"foreignKeys": {
"chatHistory_threadId_chatThread_id_fk": {
"name": "chatHistory_threadId_chatThread_id_fk",
"tableFrom": "chatHistory",
"tableTo": "chatThread",
"columnsFrom": ["threadId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"chatThread": {
"name": "chatThread",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"firstMessage": {
"name": "firstMessage",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"chatThread_user_idx": {
"name": "chatThread_user_idx",
"columns": ["userId"],
"isUnique": false
}
},
"foreignKeys": {
"chatThread_userId_user_id_fk": {
"name": "chatThread_userId_user_id_fk",
"tableFrom": "chatThread",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"contentToSpace": {
"name": "contentToSpace",
"columns": {
@ -411,7 +524,7 @@
},
"user": {
"name": "user",
"type": "integer",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1718412145023,
"tag": "0000_absurd_pandemic",
"when": 1719075265633,
"tag": "0000_conscious_arachne",
"breakpoints": true
}
]

View file

@ -154,3 +154,40 @@ export type StoredSpace = typeof space.$inferSelect;
export type ChachedSpaceContent = StoredContent & {
space: number;
};
export const chatThreads = createTable(
"chatThread",
{
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
firstMessage: text("firstMessage").notNull(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(thread) => ({
userIdx: index("chatThread_user_idx").on(thread.userId),
}),
);
export const chatHistory = createTable(
"chatHistory",
{
id: integer("id").notNull().primaryKey({ autoIncrement: true }),
threadId: text("threadId")
.notNull()
.references(() => chatThreads.id, { onDelete: "cascade" }),
question: text("question").notNull(),
answer: text("answerParts"), // Single answer part as string
answerSources: text("answerSources"), // JSON stringified array of objects
answerJustification: text("answerJustification"),
},
(history) => ({
threadIdx: index("chatHistory_thread_idx").on(history.threadId),
}),
);
export type ChatThread = typeof chatThreads.$inferSelect;
export type ChatHistory = typeof chatHistory.$inferSelect;

View file

@ -1,18 +1,20 @@
import { z } from "zod";
export const SourceZod = z.object({
type: z.string(),
source: z.string(),
title: z.string(),
content: z.string(),
numChunks: z.number().optional().default(1),
});
export type Source = z.infer<typeof SourceZod>;
export const ChatHistoryZod = z.object({
question: z.string(),
answer: z.object({
parts: z.array(z.object({ text: z.string() })),
sources: z.array(
z.object({
type: z.enum(["note", "page", "tweet"]),
source: z.string(),
title: z.string(),
content: z.string(),
numChunks: z.number().optional().default(1),
}),
),
parts: z.array(z.object({ text: z.string().optional() })),
sources: z.array(SourceZod),
justification: z.string().optional(),
}),
});
@ -52,5 +54,8 @@ export function convertChatHistoryList(
);
});
// THE LAST ASSISTANT CONTENT WILL ALWAYS BE EMPTY, so we remove it
convertedChats.pop();
return convertedChats;
}