Skip to content

Commit d6c1412

Browse files
committed
WIP: improved Ui, retry logic, and working on RAG
1 parent 657e99d commit d6c1412

12 files changed

Lines changed: 194 additions & 119 deletions

File tree

.github/workflows/docker-build-manual.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on: workflow_dispatch
55
jobs:
66
backend:
77
runs-on: ubuntu-24.04-arm
8+
timeout-minutes: 10
89
steps:
910
- uses: actions/checkout@v4
1011

.github/workflows/docker-build-push.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
jobs:
1010
changes:
1111
runs-on: ubuntu-latest
12+
timeout-minutes: 5
1213
outputs:
1314
backend: ${{ steps.set-filters.outputs.backend }}
1415
steps:
@@ -40,6 +41,7 @@ jobs:
4041
needs: changes
4142
if: needs.changes.outputs.backend == 'true'
4243
runs-on: ubuntu-24.04-arm
44+
timeout-minutes: 10
4345
steps:
4446
- uses: actions/checkout@v4
4547

backend/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"@langchain/google-genai": "^2.1.24",
3030
"@langchain/groq": "^1.1.4",
3131
"@langchain/langgraph-checkpoint-redis": "^1.0.2",
32-
"@langchain/openrouter": "^0.1.5",
3332
"@langchain/pinecone": "^1.0.1",
3433
"@langchain/tavily": "^1.2.0",
3534
"@pinecone-database/pinecone": "^7.1.0",

backend/src/controllers/chat.controllers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,18 @@ export async function handleDeleteAllUserChat(c: Context) {
7070

7171
return c.json({ success: true, deleted: count });
7272
}
73+
export async function handleChatStatus(c: Context) {
74+
const { chatId } = c.get("param");
75+
const userId = c.get("user");
76+
77+
const chat = await prisma.chat.findUnique({
78+
where: { id: chatId, userId },
79+
select: {
80+
ragStatus: true,
81+
},
82+
});
83+
84+
if (!chat) throw new NotFoundError("Chat not found or unauthorized");
85+
86+
return c.json(chat, 200);
87+
}

backend/src/prompts/system.prompt.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ ${memoryContext.length ? `### KNOWN FACTS ABOUT THE USER\n${memoryContext.join("
5757
- **AVOID ITERATION**: Do not search, analyze, and then search again. Try to get the answer in the first attempt.
5858
5959
### FORMATTING STANDARDS
60-
**1. Math & LaTeX**
61-
- Inline math: $...$
62-
- Block math: $$...$$
60+
**1. Math & Currency**
61+
- Inline math: ONLY use double dollar signs \`$$...$$\` for inline math equations so they render correctly via KaTeX. DO NOT use single dollar signs \`$...\` for math.
62+
- Block math: Use double dollar signs \`$$...$$\` on separate lines.
63+
- Currency: Write currency normally like \`$37,000\`. It will render as normal text because single-dollar math parsing is disabled.
6364
6465
**2. Markdown Tables**
6566
- Do NOT use multi-line code blocks inside tables, place it **outside** the table..

backend/src/routes/chat.routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
handleGetUserChats,
66
handleRenameUserChat,
77
handleDeleteAllUserChat,
8+
handleChatStatus,
89
} from "../controllers/chat.controllers.js";
910
import { clerkMiddleware } from "@hono/clerk-auth";
1011
import { checkUser } from "../middlewares/auth.middlewares.js";
@@ -14,6 +15,7 @@ const router = new Hono();
1415

1516
router.get("/", clerkMiddleware(), checkUser, handleGetUserChats);
1617
router.post("/", clerkMiddleware(), checkUser, validate(querySchema), handleCreateUserChat);
18+
router.get("/:chatId/status", clerkMiddleware(), checkUser, validateParams(chatIdParamSchema), handleChatStatus);
1719
router.patch("/:chatId", clerkMiddleware(), checkUser, validateParams(chatIdParamSchema), validate(chatRenameSchema), handleRenameUserChat);
1820
router.delete("/:chatId", clerkMiddleware(), checkUser, validateParams(chatIdParamSchema), handleDeleteUserChat);
1921
router.delete("/", clerkMiddleware(), checkUser, handleDeleteAllUserChat);

frontend/src/components/containers/InputContainer.tsx

Lines changed: 123 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import React, { useState, useRef, useEffect } from "react";
2-
import { ArrowUp, Loader2, Paperclip, X, AlertCircle, FileText } from "lucide-react";
2+
import { ArrowUp, Loader2, Paperclip, X, AlertCircle, FileText, RefreshCw } from "lucide-react";
33
import ModelSelector from "@/components/ModelSelector";
44
import { useIsMobile } from "@/hooks";
55
import { modelsList } from "@app/shared/src/models";
66
import { useAuth } from "@clerk/clerk-react";
77
import { useParams, useNavigate } from "react-router-dom";
88
import type { UploadedFile } from "@app/shared/src/types";
99
import { uploadToCloudinary } from "@/utils/cloudinary";
10+
import { toast } from "sonner";
1011

1112
type Attachment = {
1213
id: string;
@@ -26,6 +27,19 @@ type Props = {
2627
startRagPolling: () => void;
2728
};
2829

30+
const SUPPORTED_FORMATS = [
31+
"application/pdf",
32+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
33+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
34+
"text/csv",
35+
"text/plain",
36+
"image/jpeg",
37+
"image/png",
38+
"image/webp",
39+
];
40+
41+
const SUPPORTED_EXTENSIONS_DISPLAY = ".pdf, .docx, .pptx, .csv, .txt, .jpg, .jpeg, .png, .webp";
42+
2943
export default function InputContainer({ sendMessage, isGenerating, selectedModel, onStop, onModelChange, autoFocus, isRagProcessing, startRagPolling }: Props) {
3044
const [message, setMessage] = useState("");
3145
const { getToken } = useAuth();
@@ -40,62 +54,102 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
4054
const textareaRef = useRef<HTMLTextAreaElement>(null);
4155
const isMobile = useIsMobile();
4256

57+
const uploadSingleFile = async (attachment: Attachment) => {
58+
try {
59+
setAttachments((prev) =>
60+
prev.map((item) => (item.id === attachment.id ? { ...item, status: "uploading" } : item))
61+
);
62+
63+
const data = await uploadToCloudinary({ file: attachment.file, getToken });
64+
65+
let newChatId = undefined;
66+
if (!data.fileType.includes("image")) {
67+
const token = await getToken();
68+
const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/upload/rag`, {
69+
method: 'POST',
70+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
71+
body: JSON.stringify({
72+
fileUrl: data.fileUrl,
73+
fileName: data.fileName,
74+
fileType: data.fileType,
75+
chatId: chatId || undefined
76+
})
77+
});
78+
const apiData = await res.json();
79+
if (apiData.success) {
80+
startRagPolling();
81+
if (apiData.chatId && !chatId) {
82+
newChatId = apiData.chatId;
83+
}
84+
}
85+
}
86+
87+
setAttachments((prev) =>
88+
prev.map((item) => (item.id === attachment.id ? { ...item, status: "success", uploadData: data } : item))
89+
);
90+
91+
if (newChatId) {
92+
navigate(`/c/${newChatId}`, { replace: true });
93+
}
94+
} catch {
95+
setAttachments((prev) =>
96+
prev.map((item) => (item.id === attachment.id ? { ...item, status: "error" } : item))
97+
);
98+
}
99+
};
100+
43101
const processFiles = async (files: File[]) => {
44102
if (!files.length) return;
45103

46-
const newAttachments: Attachment[] = files.map((file) => ({
104+
const currentCount = attachments.length;
105+
const remainingSlots = 4 - currentCount;
106+
107+
if (remainingSlots <= 0) {
108+
toast.error("Maximum 4 files allowed per message");
109+
return;
110+
}
111+
112+
const validFiles = files.filter(file => {
113+
const isSupported = SUPPORTED_FORMATS.includes(file.type) ||
114+
file.name.toLowerCase().endsWith('.txt') ||
115+
file.name.toLowerCase().endsWith('.csv') ||
116+
file.name.toLowerCase().endsWith('.pdf') ||
117+
file.name.toLowerCase().endsWith('.docx') ||
118+
file.name.toLowerCase().endsWith('.pptx');
119+
120+
if (!isSupported) {
121+
toast.error(`Unsupported file type: ${file.name}`);
122+
}
123+
return isSupported;
124+
});
125+
126+
if (validFiles.length === 0) return;
127+
128+
let filesToUpload = validFiles;
129+
if (validFiles.length > remainingSlots) {
130+
toast.warning(`Only ${remainingSlots} more file${remainingSlots > 1 ? 's' : ''} can be added (4 file limit)`);
131+
filesToUpload = validFiles.slice(0, remainingSlots);
132+
}
133+
134+
const newAttachments: Attachment[] = filesToUpload.map((file) => ({
47135
id: crypto.randomUUID(),
48136
file,
49137
status: "uploading",
50138
}));
51139

52140
setAttachments((prev) => [...prev, ...newAttachments]);
53141

54-
const uploadPromises = newAttachments.map(async (attachment) => {
55-
try {
56-
const data = await uploadToCloudinary({ file: attachment.file, getToken });
57-
58-
let newChatId = undefined;
59-
if (!data.fileType.includes("image")) {
60-
const token = await getToken();
61-
const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/upload/rag`, {
62-
method: 'POST',
63-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
64-
body: JSON.stringify({
65-
fileUrl: data.fileUrl,
66-
fileName: data.fileName,
67-
fileType: data.fileType,
68-
chatId: chatId || undefined
69-
})
70-
});
71-
const apiData = await res.json();
72-
if (apiData.success) {
73-
startRagPolling();
74-
if (apiData.chatId && !chatId) {
75-
newChatId = apiData.chatId;
76-
}
77-
}
78-
}
79-
80-
setAttachments((prev) =>
81-
prev.map((item) => (item.id === attachment.id ? { ...item, status: "success", uploadData: data } : item))
82-
);
83-
84-
if (newChatId) {
85-
navigate(`/c/${newChatId}`, { replace: true });
86-
}
87-
} catch {
88-
setAttachments((prev) =>
89-
prev.map((item) => (item.id === attachment.id ? { ...item, status: "error" } : item))
90-
);
91-
}
92-
});
142+
const uploadPromises = newAttachments.map((attachment) => uploadSingleFile(attachment));
93143

94144
await Promise.allSettled(uploadPromises);
95145

96146
if (fileInputRef.current) fileInputRef.current.value = "";
97147
};
98148

149+
const handleRetryUpload = (attachment: Attachment) => {
150+
uploadSingleFile(attachment);
151+
};
152+
99153
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
100154
if (e.target.files && e.target.files.length > 0) {
101155
await processFiles(Array.from(e.target.files));
@@ -231,7 +285,7 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
231285
const isBusy = isGenerating || isGlobalUploading;
232286

233287
return (
234-
<div className="w-full flex justify-center items-center px-4 pb-6 sm:pb-4 bg-transparent relative">
288+
<div className="w-full flex justify-center items-center px-4 pb-4 bg-transparent relative">
235289
{isDragging && (
236290
<div className="fixed inset-0 z-99 flex items-center justify-center bg-black/40 backdrop-blur-sm pointer-events-none transition-all duration-200">
237291
<div className="absolute inset-4 sm:inset-6 md:inset-8 border-4 border-dashed border-white/60 rounded-3xl z-0" />
@@ -255,9 +309,19 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
255309
<img
256310
src={URL.createObjectURL(att.file)}
257311
alt="Preview"
258-
className={`h-16 w-auto rounded-lg object-contain border border-gray-100 transition-all ${att.status === "uploading" ? "opacity-50 blur-[1px]" : ""
259-
}`}
312+
className={`h-16 w-auto rounded-lg object-contain border border-gray-100 transition-all ${att.status === "uploading" ? "opacity-50 blur-[1px]" : "opacity-100"
313+
} ${att.status === "error" ? "border-red-400 bg-red-50" : ""}`}
260314
/>
315+
{att.status === "error" && (
316+
<button
317+
type="button"
318+
onClick={() => handleRetryUpload(att)}
319+
className="absolute inset-0 m-auto h-8 w-8 bg-black/60 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg z-20"
320+
title="Retry upload"
321+
>
322+
<RefreshCw size={16} />
323+
</button>
324+
)}
261325
<button
262326
type="button"
263327
onClick={() => handleRemoveAttachment(att.id)}
@@ -268,10 +332,20 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
268332
</div>
269333
) : (
270334
<div className="relative inline-flex items-center gap-2 bg-gray-50 px-3 py-2 rounded-lg border border-gray-100 w-fit max-w-full">
271-
<div className="bg-white p-1.5 rounded-md border border-gray-200 shadow-sm">
272-
<FileText size={16} className="text-primary" />
335+
<div className={`bg-white p-1.5 rounded-md border shadow-sm transition-colors ${att.status === "error" ? "border-red-200 bg-red-50" : "border-gray-200"}`}>
336+
{att.status === "error" ? (
337+
<RefreshCw
338+
size={16}
339+
className="text-red-500 cursor-pointer hover:rotate-180 transition-transform duration-500"
340+
onClick={() => handleRetryUpload(att)}
341+
/>
342+
) : (
343+
<FileText size={16} className="text-primary" />
344+
)}
273345
</div>
274-
<span className="truncate text-xs text-primary max-w-24 font-medium">{att.file.name}</span>
346+
<span className={`truncate text-xs max-w-24 font-medium ${att.status === "error" ? "text-red-600" : "text-primary"}`}>
347+
{att.file.name}
348+
</span>
275349
<button
276350
type="button"
277351
onClick={() => handleRemoveAttachment(att.id)}
@@ -317,7 +391,6 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
317391
onChange={(e) => setMessage(e.target.value)}
318392
onKeyDown={handleKeyDown}
319393
onPaste={handlePaste}
320-
disabled={isBusy}
321394
placeholder="Ask Anything"
322395
rows={1}
323396
className="w-full custom-scroll custom-scroll-xs py-2 px-1.5 text-primary text-sm focus:outline-none resize-none overflow-y-auto max-h-28 transition-all duration-200 ease-in-out"
@@ -329,7 +402,8 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
329402
type="file"
330403
multiple
331404
ref={fileInputRef}
332-
max={5}
405+
max={4}
406+
accept={SUPPORTED_EXTENSIONS_DISPLAY}
333407
onChange={handleFileChange}
334408
className="hidden"
335409
id="file-upload"

frontend/src/components/messages/ModelMessage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ type Props = {
5959
export default memo(function ModelMessage({ text, status, isCopied, onCopy, onResend, hideToolTip, isGenerating }: Props) {
6060

6161
return (
62-
<div className="max-w-full text-[15px] sm:text-sm py-2 sm:py-4 rounded-xl">
62+
<div className="max-w-full text-[15px] sm:text-sm py-3 rounded-xl">
6363
{isGenerating && text.length === 0 && !status ? (
6464
<span className="inline-block animate-pulse font-bold"></span>
6565
) : (
@@ -71,7 +71,7 @@ export default memo(function ModelMessage({ text, status, isCopied, onCopy, onRe
7171
)}
7272
{text && (
7373
<ReactMarkdown
74-
remarkPlugins={[remarkGfm, remarkMath]}
74+
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
7575
rehypePlugins={[
7676
rehypeHighlight,
7777
[rehypeKatex, { strict: false }],

frontend/src/components/tooltips/ModelMessageToolTip.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default function ModelMessageToolTip({ isCopied, onCopy, onResend }: Prop
1616
className="mr-1 p-1 text-[#1c1c1c]/80 hover:bg-[#e9e9e980] active:bg-[#e9e9e980] rounded-full"
1717
aria-label="Copy message"
1818
>
19-
{isCopied ? <Check size={14} className="transition-all duration-300" /> : <Copy size={14} />}
19+
{isCopied ? <Check size={16} className="transition-all duration-300" /> : <Copy size={16} />}
2020
</button>
2121

2222
{onResend && (
@@ -27,7 +27,7 @@ export default function ModelMessageToolTip({ isCopied, onCopy, onResend }: Prop
2727
className="mr-1 p-1 text-[#1c1c1c]/80 hover:bg-[#e9e9e980] active:bg-[#e9e9e980] rounded-full"
2828
aria-label="Send again"
2929
>
30-
<Repeat2 size={14} />
30+
<Repeat2 size={16} />
3131
</button>
3232
)}
3333
</div>

0 commit comments

Comments
 (0)