Skip to content

Commit 6e5f9e3

Browse files
committed
fix: UI improvements
1 parent 27d1ae2 commit 6e5f9e3

13 files changed

Lines changed: 153 additions & 62 deletions

File tree

backend/src/controllers/message.controllers.ts

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,38 @@ import { type Context } from "hono";
22
import { scheduleMemoryExtraction, generateAIResponse, extractImageData } from "../utils/model.utils.js";
33
import prisma from "../config/prisma.config.js";
44
import { NotFoundError } from "../utils/appError.utils.js";
5-
import { streamText } from "hono/streaming";
5+
import { streamText as honoStreamText } from "hono/streaming";
66
import logger from "../utils/logger.utils.js";
7+
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+
}
737

838
export async function handleUserMessageResponse(c: Context) {
939
const requestStartTime = new Date();
@@ -12,33 +42,11 @@ export async function handleUserMessageResponse(c: Context) {
1242
const { query, model, messageFiles } = c.get("body");
1343
const timezone = c.req.header("x-client-timezone") || "UTC";
1444

15-
return streamText(c, async (stream) => {
45+
return honoStreamText(c, async (stream) => {
1646
let fullResponse = "";
1747
let finalQuery = query;
1848

19-
try {
20-
if (messageFiles && Array.isArray(messageFiles) && messageFiles.length > 0) {
21-
await stream.write(JSON.stringify({ type: "loading", content: "Analyzing image..." }) + "\n");
22-
const extractionPromises = messageFiles.map((file: any) => {
23-
if (file.fileUrl) {
24-
return extractImageData(file.fileUrl);
25-
}
26-
return Promise.resolve(null);
27-
});
28-
29-
const extractionResults = await Promise.allSettled(extractionPromises);
30-
31-
const successfulData = extractionResults
32-
.filter((result) => result.status === "fulfilled" && result.value)
33-
.map((result: any) => result.value as string);
34-
35-
if (successfulData.length > 0) {
36-
finalQuery += `\n\nExtracted information from attached images:\n${successfulData.join("\n---\n")}\n\nUse this information if relevant when answering.`;
37-
}
38-
}
39-
} catch (error) {
40-
logger.error({ message: "Failed to process attached images", error });
41-
}
49+
finalQuery += await handleImageExtraction(messageFiles, stream);
4250

4351
try {
4452
const [chat, preferences, memories] = await Promise.all([
@@ -48,7 +56,7 @@ export async function handleUserMessageResponse(c: Context) {
4856
]);
4957

5058
if (!chat) {
51-
await stream.write(JSON.stringify({ type: "error", content: "Chat not found or unauthorized" }) + "\n");
59+
await streamError(stream, "Chat not found or unauthorized");
5260
return;
5361
}
5462

@@ -64,15 +72,15 @@ export async function handleUserMessageResponse(c: Context) {
6472
let isFirstChunk = true;
6573
for await (const chunk of aiStream) {
6674
if (isFirstChunk) {
67-
await stream.write(JSON.stringify({ type: "loading", content: null }) + "\n");
75+
await streamLoading(stream, null);
6876
isFirstChunk = false;
6977
}
7078
fullResponse += chunk;
71-
await stream.write(JSON.stringify({ type: "text", content: chunk }) + "\n");
79+
await streamText(stream, chunk);
7280
}
7381

7482
if (isFirstChunk) {
75-
await stream.write(JSON.stringify({ type: "loading", content: null }) + "\n");
83+
await streamLoading(stream, null);
7684
}
7785

7886
const filesToCreate = messageFiles?.length ? messageFiles : undefined;
@@ -84,7 +92,7 @@ export async function handleUserMessageResponse(c: Context) {
8492
messages: {
8593
create: [
8694
{
87-
text: finalQuery,
95+
text: query,
8896
role: "USER",
8997
createdAt: requestStartTime,
9098
messageFiles: filesToCreate ? { create: filesToCreate } : undefined,
@@ -102,7 +110,7 @@ export async function handleUserMessageResponse(c: Context) {
102110
chatId,
103111
error: error.message,
104112
});
105-
await stream.write(JSON.stringify({ type: "error", content: "Response generation interrupted" }) + "\n");
113+
await streamError(stream, "Response generation interrupted");
106114
}
107115
});
108116
}

backend/src/utils/stream.utils.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2+
type Streamable = { write: (str: string) => Promise<any> };
3+
4+
type StreamEventType = "text" | "loading" | "error";
5+
6+
type StreamEvent =
7+
| { type: "text"; content: string }
8+
| { type: "loading"; content: string | null }
9+
| { type: "error"; content: string };
10+
11+
export function streamEvent(stream: Streamable, event: StreamEvent): Promise<void> {
12+
return stream.write(JSON.stringify(event) + "\n");
13+
}
14+
15+
export function streamText(stream: Streamable, content: string): Promise<void> {
16+
return streamEvent(stream, { type: "text", content });
17+
}
18+
19+
export function streamLoading(stream: Streamable, content: string | null): Promise<void> {
20+
return streamEvent(stream, { type: "loading", content });
21+
}
22+
23+
export function streamError(stream: Streamable, content: string): Promise<void> {
24+
return streamEvent(stream, { type: "error", content });
25+
}
26+
27+
export type { StreamEventType, StreamEvent };

frontend/index.html

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
<!doctype html>
22
<html lang="en">
3-
<head>
4-
<meta charset="UTF-8" />
5-
<link rel="icon" type="image/webp" href="/favicon.ico" />
6-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>Sleek AI</title>
8-
</head>
9-
<body>
10-
<div id="root"></div>
11-
<script type="module" src="/src/main.tsx"></script>
12-
</body>
13-
</html>
3+
4+
<head>
5+
<meta charset="UTF-8" />
6+
<link rel="icon" type="image/webp" href="/favicon.ico" />
7+
<meta name="viewport"
8+
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, interactive-widget=overlays-content" />
9+
<title>Sleek AI</title>
10+
</head>
11+
12+
<body>
13+
<div id="root"></div>
14+
<script type="module" src="/src/main.tsx"></script>
15+
</body>
16+
17+
</html>

frontend/src/components/containers/InputContainer.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
144144
const isBusy = isGenerating || isGlobalUploading;
145145

146146
return (
147-
<div className="w-full flex justify-center items-center px-4 pb-4 bg-transparent">
148-
<div className="w-full max-w-svw sm:max-w-180 transition-all duration-300 ease-in-out">
147+
<div className="w-full flex justify-center items-center px-4 pb-4 bg-gradient-to-t from-white via-white to-transparent pt-12 pointer-events-none">
148+
<div className="w-full max-w-svw sm:max-w-180 transition-all duration-300 ease-in-out pointer-events-auto">
149149
<form onSubmit={handleSubmit}>
150150
<div className="bg-white rounded-2xl border border-primary px-3 py-2 transition-all duration-200 ease-in-out shadow-md">
151151
{attachments.length > 0 && (
@@ -214,7 +214,7 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
214214
onPaste={handlePaste}
215215
placeholder="Ask Anything"
216216
rows={1}
217-
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"
217+
className="w-full custom-scroll custom-scroll-xs py-2 px-1.5 text-primary md:text-sm text-base focus:outline-none resize-none overflow-y-auto max-h-28 transition-all duration-200 ease-in-out"
218218
/>
219219

220220
<div className="flex justify-between items-center gap-2 border-t border-white/10 pt-2 text-xs text-white">

frontend/src/components/containers/MessagesContainer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type Props = {
1212
};
1313

1414
const Header = () => <div className="h-16 sm:h-6" />;
15-
const Footer = () => <div className="h-6" />;
15+
const Footer = () => <div className="h-40" />;
1616

1717
export default function MessagesContainer({ messages, onResend, isGenerating, isFetchingMessages }: Props) {
1818
const virtuosoRef = useRef<VirtuosoHandle>(null);

frontend/src/components/messages/ModelMessage.tsx

Lines changed: 1 addition & 1 deletion
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-sm py-4 rounded-xl">
62+
<div className="max-w-full text-base md:text-sm py-4 rounded-xl">
6363
{isGenerating && text.length === 0 && !status ? (
6464
<span className="inline-block animate-pulse font-bold"></span>
6565
) : (

frontend/src/components/messages/UserMessage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export default memo(function UserMessage({ message, isCopied, onCopy }: Props) {
8989
</button>
9090
)}
9191

92-
<p className="text-sm leading-normal whitespace-pre-wrap break-words">{displayedText}</p>
92+
<p className="text-base md:text-sm leading-normal whitespace-pre-wrap break-words">{displayedText}</p>
9393
</div>
9494
)}
9595

frontend/src/components/sidebars/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export default function Sidebar({ chats, isFetchingChats, onDeleteRequest, onRen
8080

8181
return (
8282
<>
83-
{isMobile && !collapsed && <div className="fixed inset-0 bg-black/40 z-10" onClick={() => setCollapsed(true)} />}
83+
{isMobile && !collapsed && <div className="fixed inset-0 bg-black/40 z-20" onClick={() => setCollapsed(true)} />}
8484
<aside
8585
ref={sidebarRef}
8686
className="fixed md:relative h-dvh w-3/4 sm:w-64 rounded-r-3xl sm:rounded-r-none bg-dark border-r border-gray-500/20 flex flex-col z-30 shrink-0"

frontend/src/hooks/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ import useIsMobile from "./useIsMobile";
44
import useMessages from "./useMessage";
55
import useModel from "./useModel";
66
import useLastAssistantId from "./useLastAssistantId";
7+
import { useKeyboardOffset } from "./useKeyboardOffset";
78

8-
export { useChat, useChatDeletion, useIsMobile, useMessages, useModel, useLastAssistantId };
9+
export { useChat, useChatDeletion, useIsMobile, useMessages, useModel, useLastAssistantId, useKeyboardOffset };
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useEffect, useState } from "react";
2+
3+
export function useKeyboardOffset() {
4+
const [offset, setOffset] = useState(0);
5+
6+
useEffect(() => {
7+
if (!window.visualViewport) return;
8+
9+
const handleResize = () => {
10+
const viewport = window.visualViewport;
11+
if (!viewport) return;
12+
13+
// Calculate how far the bottom of the visual viewport is from the bottom of the layout viewport
14+
const offsetBottom = window.innerHeight - viewport.height - viewport.offsetTop;
15+
16+
setOffset(Math.max(0, offsetBottom));
17+
};
18+
19+
window.visualViewport.addEventListener("resize", handleResize);
20+
window.visualViewport.addEventListener("scroll", handleResize);
21+
handleResize();
22+
23+
return () => {
24+
window.visualViewport?.removeEventListener("resize", handleResize);
25+
window.visualViewport?.removeEventListener("scroll", handleResize);
26+
};
27+
}, []);
28+
29+
return offset;
30+
}

0 commit comments

Comments
 (0)