Skip to content

Commit 24c0156

Browse files
committed
WIP: RAG based chats
1 parent 775c3c0 commit 24c0156

23 files changed

Lines changed: 591 additions & 181 deletions

backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@
2626
"@clerk/backend": "^2.33.0",
2727
"@hono/clerk-auth": "^3.1.0",
2828
"@langchain/core": "^1.1.30",
29+
"@langchain/google-genai": "^2.1.24",
2930
"@langchain/groq": "^1.1.4",
3031
"@langchain/langgraph-checkpoint-redis": "^1.0.2",
3132
"@langchain/openrouter": "^0.1.5",
33+
"@langchain/pinecone": "^1.0.1",
3234
"@langchain/tavily": "^1.2.0",
35+
"@pinecone-database/pinecone": "^7.1.0",
3336
"@prisma/adapter-pg": "^7.4.2",
3437
"@prisma/client": "^7.4.2",
3538
"bullmq": "^5.70.2",
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- CreateEnum
2+
CREATE TYPE "RagStatus" AS ENUM ('IDLE', 'PROCESSING', 'COMPLETED', 'FAILED');
3+
4+
-- AlterTable
5+
ALTER TABLE "Chat" ADD COLUMN "isRag" BOOLEAN NOT NULL DEFAULT false,
6+
ADD COLUMN "ragStatus" "RagStatus" NOT NULL DEFAULT 'IDLE';

backend/prisma/schema.prisma

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,19 @@ model UserPreference {
4848
user User @relation(fields: [userId], references: [clerkId], onDelete: Cascade)
4949
}
5050

51+
enum RagStatus {
52+
IDLE
53+
PROCESSING
54+
COMPLETED
55+
FAILED
56+
}
57+
5158
model Chat {
5259
id String @id @default(uuid())
5360
userId String
5461
title String?
62+
isRag Boolean @default(false)
63+
ragStatus RagStatus @default(IDLE)
5564
createdAt DateTime @default(now())
5665
updatedAt DateTime @updatedAt
5766
user User @relation(fields: [userId], references: [clerkId])

backend/src/app.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ app.use(
1515
import serverRoutes from "./routes/server.routes.js";
1616
import chatRouter from "./routes/chat.routes.js";
1717
import messageRouter from "./routes/message.routes.js";
18-
import clerkRouter from "./routes/clerk.routes.js";
18+
import webHookRouter from "./routes/webHook.routes.js";
1919
import userRouter from "./routes/user.routes.js";
2020
import searchRouter from "./routes/search.routes.js";
2121
import uploadRouter from "./routes/upload.routes.js";
@@ -27,34 +27,10 @@ app.route("/api/chat/:chatId/message", messageRouter);
2727
app.route("/api/user", userRouter);
2828
app.route("/api/upload", uploadRouter);
2929
app.route("/api/search", searchRouter);
30-
app.route("/api/clerk", clerkRouter);
30+
app.route("/api/webhooks", webHookRouter);
3131

3232
app.onError(errorHandler);
3333

34-
// import { Queue } from "bullmq";
35-
36-
// const fileIngestQueue = new Queue("file-ingest", {
37-
// connection: {
38-
// url: process.env.REDIS_URL as string,
39-
// },
40-
// defaultJobOptions: {
41-
// attempts: 3,
42-
// backoff: {
43-
// type: "exponential",
44-
// delay: 3000,
45-
// },
46-
// removeOnComplete: true,
47-
// removeOnFail: false,
48-
// },
49-
// });
50-
51-
// fileIngestQueue.add("ingest", {
52-
// fileUrl: "https://res.cloudinary.com/dzxcifimr/raw/upload/v1768761888/chatty-ai/qytcyksxdqadx2xa4nen.txt",
53-
// userId: "user_38KrOBnsnDtO35wpRPwMvdfPhQU",
54-
// chatId: "1e4efe47-424f-4fa0-9824-57a518ad089d",
55-
// fileId: "57ae0bf3-8ad8-4c14-abd0-f1eec0c60eed",
56-
// });
57-
5834
export default {
5935
port: process.env.PORT as string,
6036
idleTimeout: 30,

backend/src/config/queue.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Queue } from "bullmq";
2+
3+
export const fileIngestQueue = new Queue("file-ingest", {
4+
connection: {
5+
url: process.env.REDIS_URL as string,
6+
},
7+
defaultJobOptions: {
8+
attempts: 2,
9+
},
10+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { GoogleGenerativeAIEmbeddings } from "@langchain/google-genai";
2+
import { PineconeStore } from "@langchain/pinecone";
3+
import { Pinecone } from "@pinecone-database/pinecone";
4+
5+
export const embeddings = new GoogleGenerativeAIEmbeddings({
6+
model: process.env.GOOGLE_EMBEDDINGS_MODEL as string,
7+
apiKey: process.env.GOOGLE_API_KEY as string,
8+
});
9+
10+
export const pinecone = new Pinecone({
11+
apiKey: process.env.PINECONE_API_KEY as string,
12+
});
13+
14+
export const index = pinecone.Index({ name: process.env.PINECONE_INDEX_NAME as string });
15+
16+
export const vectorStore = await PineconeStore.fromExistingIndex(embeddings, {
17+
pineconeIndex: index,
18+
});

backend/src/controllers/message.controllers.ts

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,8 @@ import { NotFoundError } from "../utils/appError.utils.js";
55
import { streamText as honoStreamText } from "hono/streaming";
66
import logger from "../utils/logger.utils.js";
77
import { streamLoading, streamText, streamError } from "../utils/stream.utils.js";
8-
import { type UploadedFile } from "@app/shared/src/schemas/message.schema.js"
9-
10-
async function handleImageExtraction(messageFiles: UploadedFile[], stream: any): Promise<string> {
11-
if (messageFiles?.length === 0) return "";
12-
13-
try {
14-
await streamLoading(stream, "Analyzing image...");
15-
const extractionPromises = messageFiles.map((file: UploadedFile) => {
16-
if (file.fileUrl) {
17-
return extractImageData(file.fileUrl);
18-
}
19-
return Promise.resolve(null);
20-
});
21-
22-
const extractionResults = await Promise.allSettled(extractionPromises);
23-
24-
const successfulData = extractionResults
25-
.filter((result) => result.status === "fulfilled" && result.value)
26-
.map((result: any) => result.value as string);
27-
28-
if (successfulData.length > 0) {
29-
return `\n\nExtracted information from attached images:\n${successfulData.join("\n---\n")}\n\nUse this information if relevant when answering.`;
30-
}
31-
} catch (error) {
32-
logger.error({ message: "Failed to process attached images", error });
33-
}
34-
35-
return "";
36-
}
8+
import { type UploadedFile } from "@app/shared/src/schemas/message.schema.js";
9+
import { vectorStore } from "../config/vectorStore.config.js";
3710

3811
export async function handleUserMessageResponse(c: Context) {
3912
const requestStartTime = new Date();
@@ -50,7 +23,7 @@ export async function handleUserMessageResponse(c: Context) {
5023

5124
try {
5225
const [chat, preferences, memories] = await Promise.all([
53-
prisma.chat.findUnique({ where: { id: chatId, userId }, select: { id: true } }),
26+
prisma.chat.findUnique({ where: { id: chatId, userId }, select: { id: true, isRag: true } }),
5427
prisma.userPreference.upsert({ where: { userId }, create: { userId }, update: {} }),
5528
prisma.userMemory.findMany({ where: { userId }, select: { content: true } }),
5629
]);
@@ -60,6 +33,23 @@ export async function handleUserMessageResponse(c: Context) {
6033
return;
6134
}
6235

36+
if (chat.isRag && query.trim() !== "") {
37+
try {
38+
await streamLoading(stream, "Searching documents...");
39+
40+
const results = await vectorStore.similaritySearch(query, 4, {
41+
chatId: chat.id,
42+
});
43+
44+
if (results.length > 0) {
45+
const context = results.map((r) => r.pageContent).join("\n\n---\n\n");
46+
finalQuery = `Context Information:\n${context}\n\nUser Query: ${finalQuery}\n\nPlease answer the user query using the context information provided above if it is relevant.`;
47+
}
48+
} catch (err) {
49+
logger.error({ message: "Vector search failed", error: err, chatId });
50+
}
51+
}
52+
6353
const aiStream = generateAIResponse({
6454
query: finalQuery,
6555
threadId: chatId,
@@ -85,7 +75,7 @@ export async function handleUserMessageResponse(c: Context) {
8575

8676
const filesToCreate = messageFiles?.length ? messageFiles : undefined;
8777

88-
await prisma.chat.update({
78+
const createdChat = await prisma.chat.update({
8979
where: { id: chatId },
9080
data: {
9181
updatedAt: requestStartTime,
@@ -101,6 +91,7 @@ export async function handleUserMessageResponse(c: Context) {
10191
],
10292
},
10393
},
94+
include: { messages: { include: { messageFiles: true } } },
10495
});
10596

10697
scheduleMemoryExtraction(userId, query, memories);
@@ -115,6 +106,34 @@ export async function handleUserMessageResponse(c: Context) {
115106
});
116107
}
117108

109+
async function handleImageExtraction(messageFiles: UploadedFile[], stream: any) {
110+
if (!messageFiles?.length) return "";
111+
112+
const imageFiles = messageFiles.filter((file) => file.fileType?.includes("image") && file.fileUrl);
113+
114+
if (imageFiles.length === 0) return "";
115+
116+
try {
117+
await streamLoading(stream, "Analyzing image...");
118+
119+
const extractionPromises = imageFiles.map((file) => extractImageData(file.fileUrl));
120+
121+
const extractionResults = await Promise.allSettled(extractionPromises);
122+
123+
const successfulData = extractionResults
124+
.filter((result) => result.status === "fulfilled" && result.value)
125+
.map((result: any) => result.value as string);
126+
127+
if (successfulData.length > 0) {
128+
return `\n\nExtracted information from attached images:\n${successfulData.join("\n---\n")}\n\nUse this information if relevant when answering.`;
129+
}
130+
} catch (error) {
131+
logger.error({ message: "Failed to process attached images", error });
132+
}
133+
134+
return "";
135+
}
136+
118137
export async function handleGetAllChatMessages(c: Context) {
119138
const userId = c.get("user");
120139
const { chatId } = c.get("param");
@@ -124,6 +143,8 @@ export async function handleGetAllChatMessages(c: Context) {
124143
select: {
125144
id: true,
126145
title: true,
146+
ragStatus: true,
147+
isRag: true,
127148
messages: {
128149
orderBy: { createdAt: "asc" },
129150
select: {

backend/src/controllers/upload.controllers.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { type Context } from "hono";
22
import cloudinary from "../config/cloudinary.config.js";
3+
import prisma from "../config/prisma.config.js";
4+
import { fileIngestQueue } from "../config/queue.config.js";
35

46
export async function handleFileSignature(c: Context) {
57
const timestamp = Math.floor(Date.now() / 1000);
@@ -17,3 +19,54 @@ export async function handleFileSignature(c: Context) {
1719
200,
1820
);
1921
}
22+
23+
export async function handleStartRag(c: Context) {
24+
const userId = c.get("user");
25+
const body = await c.req.json();
26+
const { fileUrl, fileName, fileType, chatId } = body;
27+
28+
if (!fileUrl) {
29+
return c.json({ error: "Missing fileUrl" }, 400);
30+
}
31+
32+
if (fileType && fileType.includes("image")) {
33+
return c.json({ error: "Image files are not supported for RAG parsing" }, 400);
34+
}
35+
36+
let targetChatId = chatId;
37+
38+
if (!targetChatId) {
39+
let title = "Document Chat";
40+
if (fileName) {
41+
const lastDot = fileName.lastIndexOf(".");
42+
title = lastDot !== -1 ? fileName.substring(0, lastDot) : fileName;
43+
}
44+
45+
const newChat = await prisma.chat.create({
46+
data: {
47+
userId,
48+
title,
49+
ragStatus: "PROCESSING",
50+
}
51+
});
52+
targetChatId = newChat.id;
53+
} else {
54+
await prisma.chat.update({
55+
where: { id: targetChatId, userId },
56+
data: {
57+
ragStatus: "PROCESSING"
58+
}
59+
});
60+
}
61+
62+
const tempFileId = crypto.randomUUID();
63+
64+
await fileIngestQueue.add("ingest", {
65+
fileUrl,
66+
userId,
67+
chatId: targetChatId,
68+
fileId: tempFileId,
69+
});
70+
71+
return c.json({ success: true, chatId: targetChatId }, 200);
72+
}

backend/src/controllers/clerk.controllers.ts renamed to backend/src/controllers/webHook.controllers.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,28 @@ export async function handleClerkWebHook(c: Context) {
3636
}
3737
return c.json({ ok: true }, 200);
3838
}
39+
40+
export async function handleRagWebhook(c: Context) {
41+
const secret = c.req.header("x-internal-secret");
42+
if (secret !== process.env.INTERNAL_SECRET) {
43+
return c.json({ error: "Unauthorized" }, 401);
44+
}
45+
46+
const { chatId, status } = await c.req.json();
47+
48+
if (!chatId || !status) {
49+
return c.json({ error: "Missing required fields" }, 400);
50+
}
51+
52+
const dataToUpdate: any = { ragStatus: status };
53+
if (status === "COMPLETED") {
54+
dataToUpdate.isRag = true;
55+
}
56+
57+
await prisma.chat.update({
58+
where: { id: chatId },
59+
data: dataToUpdate,
60+
});
61+
62+
return c.json({ success: true }, 200);
63+
}

backend/src/routes/clerk.routes.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)