Skip to content
This repository was archived by the owner on Mar 29, 2026. It is now read-only.

Commit f558f32

Browse files
committed
feat: enhance Message interface
1 parent 8702d45 commit f558f32

11 files changed

Lines changed: 272 additions & 139 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Loader2 } from 'lucide-react';
2+
3+
export const ComponentLoader = () => {
4+
return (
5+
<div className="mx-4 my-4 flex items-center justify-center">
6+
<Loader2 className="h-5 w-5 animate-spin" />
7+
</div>
8+
);
9+
};

apps/web/src/components/chat/ChatScreen.tsx

Lines changed: 123 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import React, { useCallback, useEffect, useRef, useState } from 'react';
1+
import React, { lazy, useCallback, useEffect, useRef, useState } from 'react';
22
import { useQuery } from '@tanstack/react-query';
33
import { ChevronDown, ChevronUp } from 'lucide-react';
4-
import Markdown from 'react-markdown';
54
import { useParams } from 'react-router';
65

7-
import AiThinking from '@/components/AiThinking';
86
import { Avatar, AvatarImage } from '@/components/ui/avatar';
97
import { useChatStore } from '@/hooks/useChat';
108
import { API } from '@/lib/api';
9+
import { ComponentLoader } from '../ComponentLoader';
10+
11+
const Markdown = lazy(() => import('react-markdown'));
12+
const AiThinking = lazy(() => import('@/components/AiThinking'));
1113

1214
const MemoMarkdown = React.memo(Markdown);
1315

@@ -39,16 +41,18 @@ export default function ChatScreen() {
3941
const {
4042
data: conversationData,
4143
error,
44+
isLoading,
4245
isError,
4346
} = useQuery({
4447
queryFn: () => API.conversation.getConversationId(conversationId!),
4548
queryKey: ['conversation', conversationId],
4649
refetchOnWindowFocus: false,
4750
enabled: !!conversationId,
51+
staleTime: Infinity,
4852
});
4953

5054
useEffect(() => {
51-
if (conversationData?.messages) {
55+
if (conversationData?.messages && !messages.some((e) => e.isTemporary)) {
5256
setMessages((prev) => {
5357
const newMessages = conversationData.messages ?? [];
5458
const existingMessageIds = new Set(prev.map((msg) => msg.id));
@@ -63,6 +67,7 @@ export default function ChatScreen() {
6367
return prev;
6468
});
6569
}
70+
// eslint-disable-next-line react-hooks/exhaustive-deps
6671
}, [conversationData?.messages, setMessages]);
6772

6873
// Efek untuk mengatur isThinkingOpen pada pesan AI terbaru
@@ -114,122 +119,128 @@ export default function ChatScreen() {
114119
{isError && <div>{error.message}</div>}
115120

116121
<div className="mx-auto w-full max-w-3xl px-4 pt-4">
117-
{messages.map(({ id, role, content }) => {
118-
const isUser = role === 'human';
119-
120-
function extractThinkContentFromStream(content: string) {
121-
let thinkContent = ''; // Content inside <think>...</think>
122-
let cleanContent = ''; // Content outside <think>...</think>
123-
124-
// Find the start and end of the <think> block
125-
const startThinkTagIndex = content.indexOf('<think>');
126-
const endThinkTagIndex = content.indexOf('</think>');
127-
128-
if (startThinkTagIndex === -1) {
129-
// No <think> tag found, all content is clean
130-
cleanContent = content.trim();
131-
} else if (endThinkTagIndex === -1) {
132-
// <think> tag found but no </think>, treat everything after <think> as thinkContent
133-
cleanContent = content.slice(0, startThinkTagIndex).trim();
134-
thinkContent = content.slice(startThinkTagIndex + 7).trim();
135-
} else {
136-
// Both <think> and </think> tags found, split accordingly
137-
cleanContent =
138-
content.slice(0, startThinkTagIndex).trim() +
139-
' ' +
140-
content.slice(endThinkTagIndex + 8).trim();
141-
thinkContent = content
142-
.slice(startThinkTagIndex + 7, endThinkTagIndex)
143-
.trim();
122+
{isLoading && !messages.map((e) => e.isTemporary).includes(true) ? (
123+
<ComponentLoader />
124+
) : (
125+
messages.map(({ id, role, content }) => {
126+
const isUser = role === 'human';
127+
128+
function extractThinkContentFromStream(content: string) {
129+
let thinkContent = ''; // Content inside <think>...</think>
130+
let cleanContent = ''; // Content outside <think>...</think>
131+
132+
// Find the start and end of the <think> block
133+
const startThinkTagIndex = content.indexOf('<think>');
134+
const endThinkTagIndex = content.indexOf('</think>');
135+
136+
if (startThinkTagIndex === -1) {
137+
// No <think> tag found, all content is clean
138+
cleanContent = content.trim();
139+
} else if (endThinkTagIndex === -1) {
140+
// <think> tag found but no </think>, treat everything after <think> as thinkContent
141+
cleanContent = content.slice(0, startThinkTagIndex).trim();
142+
thinkContent = content.slice(startThinkTagIndex + 7).trim();
143+
} else {
144+
// Both <think> and </think> tags found, split accordingly
145+
cleanContent =
146+
content.slice(0, startThinkTagIndex).trim() +
147+
' ' +
148+
content.slice(endThinkTagIndex + 8).trim();
149+
thinkContent = content
150+
.slice(startThinkTagIndex + 7, endThinkTagIndex)
151+
.trim();
152+
}
153+
154+
return {
155+
thinkContent: thinkContent,
156+
cleanContent: cleanContent.trim(),
157+
};
144158
}
145159

146-
return {
147-
thinkContent: thinkContent,
148-
cleanContent: cleanContent.trim(),
149-
};
150-
}
151-
152-
const { thinkContent, cleanContent } =
153-
extractThinkContentFromStream(content);
154-
155-
return (
156-
<div
157-
key={id}
158-
className={`mb-4 flex items-start gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
159-
{isUser ? (
160-
<Avatar className="relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full">
161-
<AvatarImage src={'https://avatar.vercel.sh/human'} />
162-
</Avatar>
163-
) : (
164-
<div className="bg-muted relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full">
165-
<svg
166-
xmlns="http://www.w3.org/2000/svg"
167-
width="24"
168-
height="24"
169-
viewBox="0 0 24 24"
170-
fill="none"
171-
stroke="currentColor"
172-
strokeWidth="2"
173-
strokeLinecap="round"
174-
strokeLinejoin="round"
175-
className="lucide lucide-bot">
176-
<path d="M12 8V4H8" />
177-
<rect width="16" height="12" x="4" y="8" rx="2" />
178-
<path d="M2 14h2" />
179-
<path d="M20 14h2" />
180-
<path d="M15 13v2" />
181-
<path d="M9 13v2" />
182-
</svg>
183-
</div>
184-
)}
185-
186-
{/* AI Thinking loading */}
187-
{!isUser && content.trim() === '' ? (
188-
<AiThinking />
189-
) : isUser ? (
190-
<div
191-
className={`prose dark:prose-invert bg-accent text-accent-foreground min-w-0 max-w-none rounded-lg px-4 py-2 shadow-md`}>
192-
{cleanContent && <MemoMarkdown>{cleanContent}</MemoMarkdown>}
193-
</div>
194-
) : (
195-
<div className="min-w-0 max-w-none">
196-
{thinkContent && (
197-
<div className="mb-2 w-full">
198-
{/* Toggle Button */}
199-
<button
200-
onClick={() => toggleThinking(id)}
201-
className="bg-muted flex items-center gap-1 rounded-full p-2 text-xs font-medium">
202-
<span>AI Thought</span>
203-
{isThinkingOpen[id] ? (
204-
<ChevronUp className="h-4 w-4" />
205-
) : (
206-
<ChevronDown className="h-4 w-4" />
207-
)}
208-
</button>
209-
210-
{/* AI Thought Process (Collapsible) */}
211-
<div
212-
className={`text-muted-foreground prose dark:prose-invert custom-scrollbar min-w-0 max-w-none overflow-hidden overflow-y-auto p-2 text-sm backdrop-blur-md transition-all ${
213-
isThinkingOpen[id]
214-
? 'max-h-[1000px] opacity-100'
215-
: 'hidden max-h-0 opacity-0'
216-
}`}>
217-
<MemoMarkdown>{thinkContent}</MemoMarkdown>
218-
</div>
219-
</div>
220-
)}
160+
const { thinkContent, cleanContent } =
161+
extractThinkContentFromStream(content);
162+
163+
return (
164+
<div
165+
key={id}
166+
className={`mb-4 flex items-start gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
167+
{isUser ? (
168+
<Avatar className="relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full">
169+
<AvatarImage src={'https://avatar.vercel.sh/human'} />
170+
</Avatar>
171+
) : (
172+
<div className="bg-muted relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full">
173+
<svg
174+
xmlns="http://www.w3.org/2000/svg"
175+
width="24"
176+
height="24"
177+
viewBox="0 0 24 24"
178+
fill="none"
179+
stroke="currentColor"
180+
strokeWidth="2"
181+
strokeLinecap="round"
182+
strokeLinejoin="round"
183+
className="lucide lucide-bot">
184+
<path d="M12 8V4H8" />
185+
<rect width="16" height="12" x="4" y="8" rx="2" />
186+
<path d="M2 14h2" />
187+
<path d="M20 14h2" />
188+
<path d="M15 13v2" />
189+
<path d="M9 13v2" />
190+
</svg>
191+
</div>
192+
)}
221193

194+
{/* AI Thinking loading */}
195+
{!isUser && content.trim() === '' ? (
196+
<AiThinking />
197+
) : isUser ? (
222198
<div
223-
className={`prose dark:prose-invert bg-muted min-w-0 max-w-none rounded-lg px-4 py-2 shadow-md`}>
199+
className={`prose dark:prose-invert bg-accent text-accent-foreground min-w-0 max-w-none rounded-lg px-4 py-2 shadow-md`}>
224200
{cleanContent && (
225201
<MemoMarkdown>{cleanContent}</MemoMarkdown>
226202
)}
227203
</div>
228-
</div>
229-
)}
230-
</div>
231-
);
232-
})}
204+
) : (
205+
<div className="min-w-0 max-w-none">
206+
{thinkContent && (
207+
<div className="mb-2 w-full">
208+
{/* Toggle Button */}
209+
<button
210+
onClick={() => toggleThinking(id)}
211+
className="bg-muted flex items-center gap-1 rounded-full p-2 text-xs font-medium">
212+
<span>AI Thought</span>
213+
{isThinkingOpen[id] ? (
214+
<ChevronUp className="h-4 w-4" />
215+
) : (
216+
<ChevronDown className="h-4 w-4" />
217+
)}
218+
</button>
219+
220+
{/* AI Thought Process (Collapsible) */}
221+
<div
222+
className={`text-muted-foreground prose dark:prose-invert custom-scrollbar min-w-0 max-w-none overflow-hidden overflow-y-auto p-2 text-sm backdrop-blur-md transition-all ${
223+
isThinkingOpen[id]
224+
? 'max-h-[1000px] opacity-100'
225+
: 'hidden max-h-0 opacity-0'
226+
}`}>
227+
<MemoMarkdown>{thinkContent}</MemoMarkdown>
228+
</div>
229+
</div>
230+
)}
231+
232+
<div
233+
className={`prose dark:prose-invert bg-muted min-w-0 max-w-none rounded-lg px-4 py-2 shadow-md`}>
234+
{cleanContent && (
235+
<MemoMarkdown>{cleanContent}</MemoMarkdown>
236+
)}
237+
</div>
238+
</div>
239+
)}
240+
</div>
241+
);
242+
})
243+
)}
233244
<div ref={messagesEndRef} />
234245
</div>
235246
</div>

apps/web/src/components/chat/ChatWindow.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
9090
setIsPending(true);
9191

9292
const userMessage: Message = {
93-
id: Date.now().toString(),
93+
id: `temp-${Date.now()}`,
9494
content: question,
95+
isTemporary: true,
9596
role: 'human',
97+
createdAt: new Date().toISOString(),
9698
};
9799

98100
setMessages((prev) => [...prev, userMessage]);
@@ -120,10 +122,10 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
120122
const reader = res.body?.getReader();
121123
if (!reader) throw new Error('No readable stream found');
122124

123-
const aiMessageId = Date.now().toString();
125+
const aiMessageId = `temp-${Date.now()}`;
124126
setMessages((prev) => [
125127
...prev,
126-
{ id: aiMessageId, content: '', role: 'ai' },
128+
{ id: aiMessageId, content: '', role: 'ai', isTemporary: true },
127129
]);
128130

129131
await handleStreamResponse(reader, aiMessageId);

apps/web/src/components/chat/sidebar/ChatSidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export default function ChatSidebar() {
124124
</SidebarGroupContent>
125125
) : conversationsData && conversationsData.length > 0 ? (
126126
<>
127-
<SidebarGroupContent className="custom-scrollbar max-h-96 overflow-y-auto">
127+
<SidebarGroupContent className="custom-scrollbar max-h-96 overflow-hidden overflow-y-auto">
128128
<SidebarMenu>
129129
{conversationsData.map((conversation) => {
130130
const content = conversation.messages?.length

apps/web/src/layout/ChatLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { lazy, Suspense } from 'react';
22
import { Outlet } from 'react-router';
33

4+
import { ComponentLoader } from '@/components/ComponentLoader';
45
import { SidebarProvider } from '@/components/ui/sidebar';
56

67
const ChatSidebar = lazy(() => import('@/components/chat/sidebar/ChatSidebar'));
@@ -9,7 +10,7 @@ export default function ChatLayout() {
910
return (
1011
<SidebarProvider>
1112
<div className="flex max-h-screen w-full overflow-hidden">
12-
<Suspense>
13+
<Suspense fallback={<ComponentLoader />}>
1314
<ChatSidebar />
1415
</Suspense>
1516
<main className="w-full min-w-0 max-w-full flex-1">

apps/web/src/layout/DashboardLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { lazy, Suspense } from 'react';
22
import { Outlet } from 'react-router';
33

4+
import { ComponentLoader } from '@/components/ComponentLoader';
45
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
56

67
const AppSidebar = lazy(() => import('@/components/dashboard/sidebar/Sidebar'));
@@ -9,7 +10,7 @@ const Header = lazy(() => import('@/components/dashboard/sidebar/Header'));
910
export default function DashboardLayout() {
1011
return (
1112
<SidebarProvider>
12-
<Suspense>
13+
<Suspense fallback={<ComponentLoader />}>
1314
<AppSidebar />
1415
</Suspense>
1516
<SidebarInset>

apps/web/src/pages/Agents.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { lazy } from 'react';
1+
import { lazy, Suspense } from 'react';
22
import { useQuery } from '@tanstack/react-query';
33
import { Loader2, Plus } from 'lucide-react';
44

55
import { AgentList } from '@/components/agent/AgentList';
66
import { useAgentModalStore } from '@/components/agent/modal/useAgentModal';
7+
import { ComponentLoader } from '@/components/ComponentLoader';
78
import { Button } from '@/components/ui/button';
89
import { API } from '@/lib/api';
910

@@ -101,7 +102,9 @@ export default function AgentsPage() {
101102
{isLoading ? (
102103
<Loader2 className="mx-auto animate-spin" />
103104
) : agents ? (
104-
<AgentList agents={agents} />
105+
<Suspense fallback={<ComponentLoader />}>
106+
<AgentList agents={agents} />
107+
</Suspense>
105108
) : null}
106109
</div>
107110

0 commit comments

Comments
 (0)