Spaces:
Running
Running
Chandima Prabhath
commited on
Commit
·
978caa8
1
Parent(s):
ad386aa
update
Browse files- frontend/README.md +0 -5
- frontend/src/components/chat/ChatBubble.tsx +36 -172
- frontend/src/components/chat/ChatInterface.tsx +108 -22
- frontend/src/components/chat/ChatMessage.tsx +80 -35
- frontend/src/components/chat/DeleteMessageDialog.tsx +46 -0
- frontend/src/components/chat/MessageActions.tsx +99 -0
- frontend/src/components/chat/MessageVariationControls.tsx +68 -0
- frontend/src/components/chat/ThinkingAnimation.tsx +16 -29
- frontend/src/index.css +51 -18
- frontend/src/types/chat.ts +10 -1
frontend/README.md
CHANGED
@@ -71,8 +71,3 @@ Yes, you can!
|
|
71 |
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
72 |
|
73 |
Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)
|
74 |
-
|
75 |
-
|
76 |
-
TODO:
|
77 |
-
|
78 |
-
make it that the think messege is by default colapsed and can be exapnded. implement that in a creative modern way. and make the thinking animation more noticable. also messeges after a certain msg variation are belong to that specific variention so when we switch between varietions other messages that belongs to that variention needs to be changed too. also i had to regenrate to make it the first varietion and then again to second varietion. but thats not how it needs to be. every messege is by default the first varietion. so if we regenerate the message it becomes second varietion.
|
|
|
71 |
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
72 |
|
73 |
Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)
|
|
|
|
|
|
|
|
|
|
frontend/src/components/chat/ChatBubble.tsx
CHANGED
@@ -8,27 +8,10 @@ import { Avatar, AvatarFallback } from "../ui/avatar";
|
|
8 |
import { ChatMessage } from "./ChatMessage";
|
9 |
import { ThinkingAnimation } from "./ThinkingAnimation";
|
10 |
import { toast } from '../ui/sonner';
|
11 |
-
import {
|
12 |
-
import {
|
13 |
-
import {
|
14 |
-
|
15 |
-
interface MessageVariation {
|
16 |
-
id: string;
|
17 |
-
content: string;
|
18 |
-
timestamp: Date;
|
19 |
-
}
|
20 |
-
|
21 |
-
interface Message {
|
22 |
-
id: string;
|
23 |
-
content: string;
|
24 |
-
sender: "user" | "system";
|
25 |
-
timestamp: Date;
|
26 |
-
isLoading?: boolean;
|
27 |
-
error?: boolean;
|
28 |
-
result?: any;
|
29 |
-
variations?: MessageVariation[];
|
30 |
-
activeVariation?: string;
|
31 |
-
}
|
32 |
|
33 |
interface ChatBubbleProps {
|
34 |
message: Message;
|
@@ -49,9 +32,8 @@ export const ChatBubble = ({
|
|
49 |
}: ChatBubbleProps) => {
|
50 |
const [copied, setCopied] = useState(false);
|
51 |
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
52 |
-
const isWelcomeMessage = message.id === "welcomems"
|
53 |
const isSystem = message.sender === "system";
|
54 |
-
const showCopyButton = true;
|
55 |
const hasVariations = isSystem && message.variations && message.variations.length > 0;
|
56 |
|
57 |
// If the message has variations, display the active one or the first one
|
@@ -60,7 +42,9 @@ export const ChatBubble = ({
|
|
60 |
: message.content;
|
61 |
|
62 |
const copyToClipboard = () => {
|
63 |
-
|
|
|
|
|
64 |
setCopied(true);
|
65 |
toast.success('Copied to clipboard!');
|
66 |
setTimeout(() => setCopied(false), 2000);
|
@@ -73,43 +57,11 @@ export const ChatBubble = ({
|
|
73 |
}
|
74 |
};
|
75 |
|
76 |
-
const handleSelectVariation = (variationId: string) => {
|
77 |
-
if (onSelectVariation) {
|
78 |
-
onSelectVariation(message.id, variationId);
|
79 |
-
}
|
80 |
-
};
|
81 |
-
|
82 |
-
// Function to get the current variation index and navigate through variations
|
83 |
-
const navigateVariations = (direction: 'prev' | 'next') => {
|
84 |
-
if (!hasVariations || !message.variations || message.variations.length <= 1) return;
|
85 |
-
|
86 |
-
const currentIndex = message.activeVariation
|
87 |
-
? message.variations.findIndex(v => v.id === message.activeVariation)
|
88 |
-
: 0;
|
89 |
-
|
90 |
-
let newIndex;
|
91 |
-
if (direction === 'prev') {
|
92 |
-
newIndex = (currentIndex - 1 + message.variations.length) % message.variations.length;
|
93 |
-
} else {
|
94 |
-
newIndex = (currentIndex + 1) % message.variations.length;
|
95 |
-
}
|
96 |
-
|
97 |
-
handleSelectVariation(message.variations[newIndex].id);
|
98 |
-
};
|
99 |
-
|
100 |
-
// Get current variation index for display
|
101 |
-
const getCurrentVariationIndex = () => {
|
102 |
-
if (!hasVariations || !message.variations) return 0;
|
103 |
-
return message.activeVariation
|
104 |
-
? message.variations.findIndex(v => v.id === message.activeVariation) + 1
|
105 |
-
: 1;
|
106 |
-
};
|
107 |
-
|
108 |
return (
|
109 |
<>
|
110 |
<div
|
111 |
className={cn(
|
112 |
-
"group flex w-full animate-slide-in mb-
|
113 |
isSystem ? "justify-start" : "justify-end"
|
114 |
)}
|
115 |
>
|
@@ -146,134 +98,46 @@ export const ChatBubble = ({
|
|
146 |
</div>
|
147 |
|
148 |
{/* Chat bubble footer */}
|
149 |
-
<div className="flex flex-row chat-bubble-footer justify-between">
|
150 |
{/* Time */}
|
151 |
<div className={cn(
|
152 |
-
"text-xs text-muted-foreground
|
153 |
isSystem ? "text-left" : "text-right"
|
154 |
)}>
|
155 |
{format(message.timestamp, "h:mm a")}
|
156 |
</div>
|
157 |
-
{/* Controls */}
|
158 |
-
{!isWelcomeMessage && <div className="controls flex gap-1">
|
159 |
-
{/* Variation Navigation */}
|
160 |
-
{hasVariations && message.variations && message.variations.length > 1 && (
|
161 |
-
<div className="flex items-center mt-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 rounded-md border">
|
162 |
-
<Button
|
163 |
-
variant="ghost"
|
164 |
-
size="sm"
|
165 |
-
onClick={() => navigateVariations('prev')}
|
166 |
-
disabled={message.variations.length <= 1}
|
167 |
-
className="h-6 px-1"
|
168 |
-
>
|
169 |
-
<ChevronLeft className="h-3 w-3" />
|
170 |
-
</Button>
|
171 |
-
<span className="text-xs px-1">
|
172 |
-
{getCurrentVariationIndex()}/{message.variations.length}
|
173 |
-
</span>
|
174 |
-
<Button
|
175 |
-
variant="ghost"
|
176 |
-
size="sm"
|
177 |
-
onClick={() => navigateVariations('next')}
|
178 |
-
disabled={message.variations.length <= 1}
|
179 |
-
className="h-6 px-1"
|
180 |
-
>
|
181 |
-
<ChevronRight className="h-3 w-3" />
|
182 |
-
</Button>
|
183 |
-
</div>
|
184 |
-
)}
|
185 |
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
Retry
|
197 |
-
</Button>
|
198 |
-
</div>
|
199 |
-
)}
|
200 |
-
|
201 |
-
{/* Regenerate button for system messages */}
|
202 |
-
{isSystem && !message.isLoading && onRegenerate && (
|
203 |
-
<TooltipProvider>
|
204 |
-
<Tooltip>
|
205 |
-
<TooltipTrigger asChild>
|
206 |
-
<div className="flex mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
207 |
-
<Button
|
208 |
-
variant="link"
|
209 |
-
size="sm"
|
210 |
-
onClick={() => onRegenerate(message.id)}
|
211 |
-
>
|
212 |
-
<RotateCcw className="h-3 w-3" />
|
213 |
-
</Button>
|
214 |
-
</div>
|
215 |
-
</TooltipTrigger>
|
216 |
-
<TooltipContent>
|
217 |
-
<p>Generate variation</p>
|
218 |
-
</TooltipContent>
|
219 |
-
</Tooltip>
|
220 |
-
</TooltipProvider>
|
221 |
-
)}
|
222 |
-
|
223 |
-
{/* Delete button */}
|
224 |
-
{onDelete && !message.isLoading && (
|
225 |
-
<TooltipProvider>
|
226 |
-
<Tooltip>
|
227 |
-
<TooltipTrigger asChild>
|
228 |
-
<div className="flex mt-1 opacity-0 group-hover:opacity-100">
|
229 |
-
<Button
|
230 |
-
variant="ghost"
|
231 |
-
size="sm"
|
232 |
-
onClick={() => setDeleteDialogOpen(true)}
|
233 |
-
className="text-xs flex items-center gap-1.5 text-muted-foreground hover:text-destructive hover:bg-background/50 backdrop-blur-sm"
|
234 |
-
>
|
235 |
-
<Trash2 className="h-3 w-3" />
|
236 |
-
</Button>
|
237 |
-
</div>
|
238 |
-
</TooltipTrigger>
|
239 |
-
<TooltipContent>
|
240 |
-
<p>Delete message</p>
|
241 |
-
</TooltipContent>
|
242 |
-
</Tooltip>
|
243 |
-
</TooltipProvider>
|
244 |
-
)}
|
245 |
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
|
|
255 |
</div>
|
256 |
</div>
|
257 |
</div>
|
258 |
</div>
|
259 |
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
<AlertDialogDescription>
|
266 |
-
Are you sure you want to delete this message? This action cannot be undone.
|
267 |
-
</AlertDialogDescription>
|
268 |
-
</AlertDialogHeader>
|
269 |
-
<AlertDialogFooter>
|
270 |
-
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
271 |
-
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
272 |
-
Delete
|
273 |
-
</AlertDialogAction>
|
274 |
-
</AlertDialogFooter>
|
275 |
-
</AlertDialogContent>
|
276 |
-
</AlertDialog>
|
277 |
</>
|
278 |
);
|
279 |
};
|
|
|
8 |
import { ChatMessage } from "./ChatMessage";
|
9 |
import { ThinkingAnimation } from "./ThinkingAnimation";
|
10 |
import { toast } from '../ui/sonner';
|
11 |
+
import { MessageActions } from "./MessageActions";
|
12 |
+
import { MessageVariationControls } from "./MessageVariationControls";
|
13 |
+
import { DeleteMessageDialog } from "./DeleteMessageDialog";
|
14 |
+
import { MessageVariation, Message } from "@/types/chat";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
|
16 |
interface ChatBubbleProps {
|
17 |
message: Message;
|
|
|
32 |
}: ChatBubbleProps) => {
|
33 |
const [copied, setCopied] = useState(false);
|
34 |
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
35 |
+
const isWelcomeMessage = message.id === "welcomems";
|
36 |
const isSystem = message.sender === "system";
|
|
|
37 |
const hasVariations = isSystem && message.variations && message.variations.length > 0;
|
38 |
|
39 |
// If the message has variations, display the active one or the first one
|
|
|
42 |
: message.content;
|
43 |
|
44 |
const copyToClipboard = () => {
|
45 |
+
// Strip thinking content before copying
|
46 |
+
const cleanContent = displayContent.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
47 |
+
navigator.clipboard.writeText(cleanContent);
|
48 |
setCopied(true);
|
49 |
toast.success('Copied to clipboard!');
|
50 |
setTimeout(() => setCopied(false), 2000);
|
|
|
57 |
}
|
58 |
};
|
59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
return (
|
61 |
<>
|
62 |
<div
|
63 |
className={cn(
|
64 |
+
"group flex w-full animate-slide-in mb-2",
|
65 |
isSystem ? "justify-start" : "justify-end"
|
66 |
)}
|
67 |
>
|
|
|
98 |
</div>
|
99 |
|
100 |
{/* Chat bubble footer */}
|
101 |
+
<div className="flex flex-row chat-bubble-footer justify-between items-center mt-1">
|
102 |
{/* Time */}
|
103 |
<div className={cn(
|
104 |
+
"text-xs text-muted-foreground",
|
105 |
isSystem ? "text-left" : "text-right"
|
106 |
)}>
|
107 |
{format(message.timestamp, "h:mm a")}
|
108 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
109 |
|
110 |
+
{/* Controls */}
|
111 |
+
{!isWelcomeMessage && (
|
112 |
+
<div className="controls flex gap-1 items-center">
|
113 |
+
{/* Variation controls */}
|
114 |
+
{hasVariations && message.variations && message.variations.length > 1 && (
|
115 |
+
<MessageVariationControls
|
116 |
+
message={message}
|
117 |
+
onSelectVariation={onSelectVariation}
|
118 |
+
/>
|
119 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
120 |
|
121 |
+
<MessageActions
|
122 |
+
message={message}
|
123 |
+
onRetry={onRetry}
|
124 |
+
onRegenerate={onRegenerate}
|
125 |
+
onDelete={() => setDeleteDialogOpen(true)}
|
126 |
+
onCopy={copyToClipboard}
|
127 |
+
copied={copied}
|
128 |
+
/>
|
129 |
+
</div>
|
130 |
+
)}
|
131 |
</div>
|
132 |
</div>
|
133 |
</div>
|
134 |
</div>
|
135 |
|
136 |
+
<DeleteMessageDialog
|
137 |
+
isOpen={deleteDialogOpen}
|
138 |
+
onOpenChange={setDeleteDialogOpen}
|
139 |
+
onDelete={handleDelete}
|
140 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
141 |
</>
|
142 |
);
|
143 |
};
|
frontend/src/components/chat/ChatInterface.tsx
CHANGED
@@ -7,7 +7,7 @@ import { ChatBubble } from "@/components/chat/ChatBubble";
|
|
7 |
import { Separator } from "@/components/ui/separator";
|
8 |
import { cn } from "@/lib/utils";
|
9 |
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
10 |
-
import { Message, Chat } from "@/types/chat";
|
11 |
import { ProfileModal } from "../modals/ProfileModal";
|
12 |
import { ChatSidebar } from "./ChatSidebar";
|
13 |
import { ChatInputArea } from "./ChatInputArea";
|
@@ -195,6 +195,28 @@ export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfacePr
|
|
195 |
isLoading: true
|
196 |
};
|
197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
198 |
// Update active chat with new messages
|
199 |
const updatedChat = {
|
200 |
...activeChat,
|
@@ -207,14 +229,51 @@ export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfacePr
|
|
207 |
setIsLoading(true);
|
208 |
|
209 |
try {
|
210 |
-
// Prepare chat history for the API
|
211 |
-
const chatHistory: APIMessage[] =
|
212 |
-
|
213 |
-
|
214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
215 |
role: msg.sender === "user" ? "user" : "assistant",
|
216 |
-
content:
|
217 |
-
})
|
|
|
218 |
|
219 |
const response = await apiService.queryRulings({
|
220 |
query: userMessage.content,
|
@@ -228,7 +287,9 @@ export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfacePr
|
|
228 |
...msg,
|
229 |
content: response.answer,
|
230 |
isLoading: false,
|
231 |
-
result: response.retrieved_sources
|
|
|
|
|
232 |
}
|
233 |
: msg
|
234 |
);
|
@@ -405,9 +466,15 @@ export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfacePr
|
|
405 |
const variationId = generateId();
|
406 |
const now = new Date();
|
407 |
|
408 |
-
//
|
409 |
-
|
410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
411 |
|
412 |
// Create a new variations array with the loading state
|
413 |
const updatedVariations = [
|
@@ -424,23 +491,30 @@ export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfacePr
|
|
424 |
activeVariation: variationId
|
425 |
};
|
426 |
|
|
|
|
|
|
|
|
|
427 |
const updatedChat = {
|
428 |
...activeChat,
|
429 |
-
messages:
|
430 |
};
|
431 |
|
432 |
setActiveChat(updatedChat);
|
433 |
setIsLoading(true);
|
434 |
|
435 |
-
// Prepare chat history for the API
|
436 |
-
// Include only the messages up to the user message that triggered the original response
|
437 |
const chatHistory: APIMessage[] = activeChat.messages
|
438 |
.slice(0, userMessageIndex)
|
439 |
.filter(msg => !msg.isLoading && msg.content)
|
440 |
-
.map(msg =>
|
441 |
-
|
442 |
-
|
443 |
-
|
|
|
|
|
|
|
|
|
444 |
|
445 |
// Send the API request
|
446 |
apiService.queryRulings({
|
@@ -455,7 +529,7 @@ export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfacePr
|
|
455 |
: v
|
456 |
);
|
457 |
|
458 |
-
const finalMessages = [...
|
459 |
finalMessages[messageIndex] = {
|
460 |
...finalMessages[messageIndex],
|
461 |
variations: finalVariations,
|
@@ -515,16 +589,26 @@ export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfacePr
|
|
515 |
const messageIndex = activeChat.messages.findIndex(msg => msg.id === messageId);
|
516 |
if (messageIndex < 0) return;
|
517 |
|
518 |
-
//
|
519 |
const updatedMessages = [...activeChat.messages];
|
520 |
updatedMessages[messageIndex] = {
|
521 |
...updatedMessages[messageIndex],
|
522 |
activeVariation: variationId
|
523 |
};
|
524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
525 |
const updatedChat = {
|
526 |
...activeChat,
|
527 |
-
messages:
|
528 |
};
|
529 |
|
530 |
setActiveChat(updatedChat);
|
@@ -723,3 +807,5 @@ export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfacePr
|
|
723 |
</>
|
724 |
);
|
725 |
};
|
|
|
|
|
|
7 |
import { Separator } from "@/components/ui/separator";
|
8 |
import { cn } from "@/lib/utils";
|
9 |
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
10 |
+
import { Message, Chat, MessageVariation } from "@/types/chat";
|
11 |
import { ProfileModal } from "../modals/ProfileModal";
|
12 |
import { ChatSidebar } from "./ChatSidebar";
|
13 |
import { ChatInputArea } from "./ChatInputArea";
|
|
|
195 |
isLoading: true
|
196 |
};
|
197 |
|
198 |
+
// Find the active variation if we're replying to a message that has variations
|
199 |
+
let parentVariationId: string | undefined;
|
200 |
+
let parentMessageId: string | undefined;
|
201 |
+
|
202 |
+
// Find the last system message that might have variations
|
203 |
+
const lastMessages = [...activeChat.messages].reverse();
|
204 |
+
for (const msg of lastMessages) {
|
205 |
+
if (msg.sender === "system" && msg.variations && msg.variations.length > 0 && msg.activeVariation) {
|
206 |
+
parentMessageId = msg.id;
|
207 |
+
parentVariationId = msg.activeVariation;
|
208 |
+
break;
|
209 |
+
}
|
210 |
+
}
|
211 |
+
|
212 |
+
// If responding to a variation, link these messages to that variation
|
213 |
+
if (parentMessageId && parentVariationId) {
|
214 |
+
userMessage.parentMessageId = parentMessageId;
|
215 |
+
userMessage.variationId = parentVariationId;
|
216 |
+
loadingMessage.parentMessageId = parentMessageId;
|
217 |
+
loadingMessage.variationId = parentVariationId;
|
218 |
+
}
|
219 |
+
|
220 |
// Update active chat with new messages
|
221 |
const updatedChat = {
|
222 |
...activeChat,
|
|
|
229 |
setIsLoading(true);
|
230 |
|
231 |
try {
|
232 |
+
// Prepare chat history for the API based on the active variation path
|
233 |
+
const chatHistory: APIMessage[] = [];
|
234 |
+
|
235 |
+
// Build chat history based on the active variation path
|
236 |
+
const getMessagesForHistory = (messages: Message[]): Message[] => {
|
237 |
+
const result: Message[] = [];
|
238 |
+
|
239 |
+
for (const msg of messages) {
|
240 |
+
if (msg.isLoading) continue;
|
241 |
+
|
242 |
+
// If this is a system message with variations, use the active variation content
|
243 |
+
if (msg.sender === "system" && msg.variations && msg.variations.length > 0 && msg.activeVariation) {
|
244 |
+
const activeVar = msg.variations.find(v => v.id === msg.activeVariation);
|
245 |
+
if (activeVar) {
|
246 |
+
// Add the message with the active variation content
|
247 |
+
result.push({
|
248 |
+
...msg,
|
249 |
+
content: activeVar.content
|
250 |
+
});
|
251 |
+
}
|
252 |
+
} else {
|
253 |
+
// Add regular messages
|
254 |
+
result.push(msg);
|
255 |
+
}
|
256 |
+
}
|
257 |
+
|
258 |
+
return result;
|
259 |
+
};
|
260 |
+
|
261 |
+
// Get messages following the active variation path
|
262 |
+
const historyMessages = getMessagesForHistory(updatedChat.messages.slice(0, -2));
|
263 |
+
|
264 |
+
// Convert to API format
|
265 |
+
for (const msg of historyMessages) {
|
266 |
+
if (!msg.content) continue;
|
267 |
+
|
268 |
+
// Strip thinking content from messages before sending to API
|
269 |
+
const cleanedContent = msg.content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
270 |
+
if (!cleanedContent) continue;
|
271 |
+
|
272 |
+
chatHistory.push({
|
273 |
role: msg.sender === "user" ? "user" : "assistant",
|
274 |
+
content: cleanedContent
|
275 |
+
});
|
276 |
+
}
|
277 |
|
278 |
const response = await apiService.queryRulings({
|
279 |
query: userMessage.content,
|
|
|
287 |
...msg,
|
288 |
content: response.answer,
|
289 |
isLoading: false,
|
290 |
+
result: response.retrieved_sources,
|
291 |
+
variations: [{ id: loadingMessage.id + "-original", content: response.answer, timestamp: new Date() }],
|
292 |
+
activeVariation: loadingMessage.id + "-original"
|
293 |
}
|
294 |
: msg
|
295 |
);
|
|
|
466 |
const variationId = generateId();
|
467 |
const now = new Date();
|
468 |
|
469 |
+
// Get existing variations or initialize if none exist
|
470 |
+
let existingVariations = message.variations || [];
|
471 |
+
|
472 |
+
// If there are no variations yet, add the original content as the first variation
|
473 |
+
if (existingVariations.length === 0) {
|
474 |
+
existingVariations = [
|
475 |
+
{ id: message.id + "-original", content: message.content, timestamp: message.timestamp }
|
476 |
+
];
|
477 |
+
}
|
478 |
|
479 |
// Create a new variations array with the loading state
|
480 |
const updatedVariations = [
|
|
|
491 |
activeVariation: variationId
|
492 |
};
|
493 |
|
494 |
+
// Find and remove any messages that were children of the previous variation
|
495 |
+
// We'll preserve the core tree but remove messages specific to other variations
|
496 |
+
const messagesToKeep = updatedMessages.slice(0, messageIndex + 1);
|
497 |
+
|
498 |
const updatedChat = {
|
499 |
...activeChat,
|
500 |
+
messages: messagesToKeep
|
501 |
};
|
502 |
|
503 |
setActiveChat(updatedChat);
|
504 |
setIsLoading(true);
|
505 |
|
506 |
+
// Prepare chat history for the API - strip thinking content
|
|
|
507 |
const chatHistory: APIMessage[] = activeChat.messages
|
508 |
.slice(0, userMessageIndex)
|
509 |
.filter(msg => !msg.isLoading && msg.content)
|
510 |
+
.map(msg => {
|
511 |
+
// Strip thinking content from messages
|
512 |
+
const cleanedContent = msg.content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
513 |
+
return {
|
514 |
+
role: msg.sender === "user" ? "user" : "assistant",
|
515 |
+
content: cleanedContent
|
516 |
+
};
|
517 |
+
});
|
518 |
|
519 |
// Send the API request
|
520 |
apiService.queryRulings({
|
|
|
529 |
: v
|
530 |
);
|
531 |
|
532 |
+
const finalMessages = [...messagesToKeep];
|
533 |
finalMessages[messageIndex] = {
|
534 |
...finalMessages[messageIndex],
|
535 |
variations: finalVariations,
|
|
|
589 |
const messageIndex = activeChat.messages.findIndex(msg => msg.id === messageId);
|
590 |
if (messageIndex < 0) return;
|
591 |
|
592 |
+
// Update the active variation for this message
|
593 |
const updatedMessages = [...activeChat.messages];
|
594 |
updatedMessages[messageIndex] = {
|
595 |
...updatedMessages[messageIndex],
|
596 |
activeVariation: variationId
|
597 |
};
|
598 |
|
599 |
+
// Keep messages up to and including the varied message
|
600 |
+
const baseMessages = updatedMessages.slice(0, messageIndex + 1);
|
601 |
+
|
602 |
+
// Find any existing messages that belong to this variation
|
603 |
+
const childMessages = activeChat.messages
|
604 |
+
.filter(msg => msg.parentMessageId === messageId && msg.variationId === variationId);
|
605 |
+
|
606 |
+
// Combine to get the complete message list
|
607 |
+
const finalMessages = [...baseMessages, ...childMessages];
|
608 |
+
|
609 |
const updatedChat = {
|
610 |
...activeChat,
|
611 |
+
messages: finalMessages
|
612 |
};
|
613 |
|
614 |
setActiveChat(updatedChat);
|
|
|
807 |
</>
|
808 |
);
|
809 |
};
|
810 |
+
|
811 |
+
export default ChatInterface;
|
frontend/src/components/chat/ChatMessage.tsx
CHANGED
@@ -1,11 +1,12 @@
|
|
1 |
|
2 |
-
// src/components/ChatMessage.tsx
|
3 |
import React, { useState, useMemo } from 'react'
|
4 |
import ReactMarkdown from 'react-markdown'
|
5 |
import remarkGfm from 'remark-gfm'
|
6 |
import rehypeRaw from 'rehype-raw'
|
7 |
import { cn } from '@/lib/utils'
|
8 |
-
import {
|
|
|
9 |
|
10 |
interface ChatMessageProps {
|
11 |
content: string
|
@@ -16,34 +17,44 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|
16 |
content,
|
17 |
className,
|
18 |
}) => {
|
|
|
|
|
|
|
|
|
19 |
|
20 |
-
|
21 |
-
|
22 |
-
// Replace <think>...</think> tags with a special format
|
23 |
-
const contentWithProcessedThinking = content.replace(
|
24 |
/<think>([\s\S]*?)<\/think>/g,
|
25 |
(_, thinkContent) => {
|
26 |
-
|
|
|
|
|
|
|
|
|
27 |
}
|
28 |
);
|
29 |
|
30 |
-
// Continue processing source tags
|
31 |
-
|
32 |
/<source\s+path=["'](.+?)["']\s*\/>/g,
|
33 |
(_match, path) => {
|
34 |
const filename = path
|
35 |
.split('/')
|
36 |
.pop()!
|
37 |
.replace(/\.[^/.]+$/, '')
|
38 |
-
// embed your ExternalLink SVG inline so you get the icon
|
39 |
return `<a href="${path}" target="_blank" class="inline-flex items-center text-xs font-medium mx-0.5 rounded-sm px-1 bg-financial-accent/10 text-financial-accent border border-financial-accent/20 hover:bg-financial-accent/20 transition-colors">
|
40 |
${filename}
|
41 |
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
42 |
<path stroke-linecap="round" stroke-linejoin="round" d="M18 13v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6m0 0v6m0-6L10 14"/>
|
43 |
</svg>
|
44 |
-
</a
|
45 |
}
|
46 |
);
|
|
|
|
|
|
|
|
|
|
|
47 |
}, [content]);
|
48 |
|
49 |
return (
|
@@ -53,30 +64,64 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|
53 |
className
|
54 |
)}
|
55 |
>
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
</div>
|
81 |
)
|
82 |
}
|
|
|
1 |
|
2 |
+
// src/components/chat/ChatMessage.tsx
|
3 |
import React, { useState, useMemo } from 'react'
|
4 |
import ReactMarkdown from 'react-markdown'
|
5 |
import remarkGfm from 'remark-gfm'
|
6 |
import rehypeRaw from 'rehype-raw'
|
7 |
import { cn } from '@/lib/utils'
|
8 |
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
|
9 |
+
import { ChevronDown, Brain } from 'lucide-react'
|
10 |
|
11 |
interface ChatMessageProps {
|
12 |
content: string
|
|
|
17 |
content,
|
18 |
className,
|
19 |
}) => {
|
20 |
+
// Extract thinking content and actual response
|
21 |
+
const { processedContent, thinkingBlocks } = useMemo(() => {
|
22 |
+
const blocks: { id: number; content: string }[] = [];
|
23 |
+
let thinkBlockCounter = 0;
|
24 |
|
25 |
+
// Extract thinking content between <think> tags
|
26 |
+
const contentWithoutThinking = content.replace(
|
|
|
|
|
27 |
/<think>([\s\S]*?)<\/think>/g,
|
28 |
(_, thinkContent) => {
|
29 |
+
blocks.push({
|
30 |
+
id: thinkBlockCounter++,
|
31 |
+
content: thinkContent.trim()
|
32 |
+
});
|
33 |
+
return ''; // Remove thinking content from the main message
|
34 |
}
|
35 |
);
|
36 |
|
37 |
+
// Continue processing source tags
|
38 |
+
const processedText = contentWithoutThinking.replace(
|
39 |
/<source\s+path=["'](.+?)["']\s*\/>/g,
|
40 |
(_match, path) => {
|
41 |
const filename = path
|
42 |
.split('/')
|
43 |
.pop()!
|
44 |
.replace(/\.[^/.]+$/, '')
|
|
|
45 |
return `<a href="${path}" target="_blank" class="inline-flex items-center text-xs font-medium mx-0.5 rounded-sm px-1 bg-financial-accent/10 text-financial-accent border border-financial-accent/20 hover:bg-financial-accent/20 transition-colors">
|
46 |
${filename}
|
47 |
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
48 |
<path stroke-linecap="round" stroke-linejoin="round" d="M18 13v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6m0 0v6m0-6L10 14"/>
|
49 |
</svg>
|
50 |
+
</a>`;
|
51 |
}
|
52 |
);
|
53 |
+
|
54 |
+
return {
|
55 |
+
processedContent: processedText.trim(),
|
56 |
+
thinkingBlocks: blocks
|
57 |
+
};
|
58 |
}, [content]);
|
59 |
|
60 |
return (
|
|
|
64 |
className
|
65 |
)}
|
66 |
>
|
67 |
+
{/* First render the thinking blocks if any */}
|
68 |
+
{thinkingBlocks.length > 0 && (
|
69 |
+
<Collapsible
|
70 |
+
className="think-collapsible my-3 rounded-lg bg-financial-accent/5 mb-4"
|
71 |
+
defaultOpen={false}
|
72 |
+
>
|
73 |
+
<CollapsibleTrigger className="flex items-center gap-2 w-full p-2 text-left hover:bg-financial-accent/10 rounded-t-lg">
|
74 |
+
<div className="flex items-center gap-2 w-full">
|
75 |
+
<div className="thinking-brain-small relative">
|
76 |
+
<Brain className="h-4 w-4 text-financial-accent" />
|
77 |
+
</div>
|
78 |
+
<span className="text-xs font-medium text-financial-accent">Thoughts</span>
|
79 |
+
<ChevronDown className="h-4 w-4 text-financial-accent/70 transition-transform duration-200 ml-auto" />
|
80 |
+
</div>
|
81 |
+
</CollapsibleTrigger>
|
82 |
+
|
83 |
+
<CollapsibleContent>
|
84 |
+
<div className="think-block p-3 text-sm text-muted-foreground bg-financial-accent/5">
|
85 |
+
{thinkingBlocks.map((block, index) => (
|
86 |
+
<ReactMarkdown
|
87 |
+
key={`thinking-${block.id}`}
|
88 |
+
remarkPlugins={[remarkGfm]}
|
89 |
+
rehypePlugins={[rehypeRaw]}
|
90 |
+
>
|
91 |
+
{block.content}
|
92 |
+
</ReactMarkdown>
|
93 |
+
))}
|
94 |
+
</div>
|
95 |
+
</CollapsibleContent>
|
96 |
+
</Collapsible>
|
97 |
+
)}
|
98 |
+
|
99 |
+
{/* Then render the actual response content */}
|
100 |
+
{processedContent && (
|
101 |
+
<ReactMarkdown
|
102 |
+
remarkPlugins={[remarkGfm]}
|
103 |
+
rehypePlugins={[rehypeRaw]}
|
104 |
+
components={{
|
105 |
+
a: ({ href, children, node, ...props }) =>
|
106 |
+
href && href.endsWith('.md') ? (
|
107 |
+
<a
|
108 |
+
href={href}
|
109 |
+
target="_blank"
|
110 |
+
rel="noopener noreferrer"
|
111 |
+
{...props}
|
112 |
+
>
|
113 |
+
{children}
|
114 |
+
</a>
|
115 |
+
) : (
|
116 |
+
<a href={href} {...props}>
|
117 |
+
{children}
|
118 |
+
</a>
|
119 |
+
),
|
120 |
+
}}
|
121 |
+
>
|
122 |
+
{processedContent}
|
123 |
+
</ReactMarkdown>
|
124 |
+
)}
|
125 |
</div>
|
126 |
)
|
127 |
}
|
frontend/src/components/chat/DeleteMessageDialog.tsx
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React from 'react';
|
3 |
+
import {
|
4 |
+
AlertDialog,
|
5 |
+
AlertDialogAction,
|
6 |
+
AlertDialogCancel,
|
7 |
+
AlertDialogContent,
|
8 |
+
AlertDialogDescription,
|
9 |
+
AlertDialogFooter,
|
10 |
+
AlertDialogHeader,
|
11 |
+
AlertDialogTitle
|
12 |
+
} from "@/components/ui/alert-dialog";
|
13 |
+
|
14 |
+
interface DeleteMessageDialogProps {
|
15 |
+
isOpen: boolean;
|
16 |
+
onOpenChange: (open: boolean) => void;
|
17 |
+
onDelete: () => void;
|
18 |
+
}
|
19 |
+
|
20 |
+
export const DeleteMessageDialog: React.FC<DeleteMessageDialogProps> = ({
|
21 |
+
isOpen,
|
22 |
+
onOpenChange,
|
23 |
+
onDelete
|
24 |
+
}) => {
|
25 |
+
return (
|
26 |
+
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
|
27 |
+
<AlertDialogContent>
|
28 |
+
<AlertDialogHeader>
|
29 |
+
<AlertDialogTitle>Delete Message</AlertDialogTitle>
|
30 |
+
<AlertDialogDescription>
|
31 |
+
Are you sure you want to delete this message? This action cannot be undone.
|
32 |
+
</AlertDialogDescription>
|
33 |
+
</AlertDialogHeader>
|
34 |
+
<AlertDialogFooter>
|
35 |
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
36 |
+
<AlertDialogAction
|
37 |
+
onClick={onDelete}
|
38 |
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
39 |
+
>
|
40 |
+
Delete
|
41 |
+
</AlertDialogAction>
|
42 |
+
</AlertDialogFooter>
|
43 |
+
</AlertDialogContent>
|
44 |
+
</AlertDialog>
|
45 |
+
);
|
46 |
+
};
|
frontend/src/components/chat/MessageActions.tsx
ADDED
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React from 'react';
|
3 |
+
import { RefreshCcw, Copy, Check, Trash2, RotateCcw } from "lucide-react";
|
4 |
+
import { Button } from "@/components/ui/button";
|
5 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
6 |
+
import { Message } from "@/types/chat";
|
7 |
+
|
8 |
+
interface MessageActionsProps {
|
9 |
+
message: Message;
|
10 |
+
onRetry?: (messageId: string) => void;
|
11 |
+
onRegenerate?: (messageId: string) => void;
|
12 |
+
onDelete?: () => void;
|
13 |
+
onCopy?: () => void;
|
14 |
+
copied?: boolean;
|
15 |
+
}
|
16 |
+
|
17 |
+
export const MessageActions: React.FC<MessageActionsProps> = ({
|
18 |
+
message,
|
19 |
+
onRetry,
|
20 |
+
onRegenerate,
|
21 |
+
onDelete,
|
22 |
+
onCopy,
|
23 |
+
copied = false
|
24 |
+
}) => {
|
25 |
+
const isSystem = message.sender === "system";
|
26 |
+
|
27 |
+
return (
|
28 |
+
<>
|
29 |
+
{/* Retry button for failed messages */}
|
30 |
+
{message.error && onRetry && (
|
31 |
+
<div className="flex">
|
32 |
+
<Button
|
33 |
+
variant="secondary"
|
34 |
+
size="sm"
|
35 |
+
onClick={() => onRetry(message.id)}
|
36 |
+
className="text-xs flex items-center gap-1.5 text-muted-foreground hover:border border-financial-accent/30 bg-background/50 backdrop-blur-sm"
|
37 |
+
>
|
38 |
+
<RefreshCcw className="h-3 w-3" />
|
39 |
+
Retry
|
40 |
+
</Button>
|
41 |
+
</div>
|
42 |
+
)}
|
43 |
+
|
44 |
+
{/* Regenerate button for system messages */}
|
45 |
+
{isSystem && !message.isLoading && onRegenerate && (
|
46 |
+
<TooltipProvider>
|
47 |
+
<Tooltip>
|
48 |
+
<TooltipTrigger asChild>
|
49 |
+
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity">
|
50 |
+
<Button
|
51 |
+
variant="link"
|
52 |
+
size="sm"
|
53 |
+
onClick={() => onRegenerate(message.id)}
|
54 |
+
>
|
55 |
+
<RotateCcw className="h-3 w-3" />
|
56 |
+
</Button>
|
57 |
+
</div>
|
58 |
+
</TooltipTrigger>
|
59 |
+
<TooltipContent>
|
60 |
+
<p>Generate variation</p>
|
61 |
+
</TooltipContent>
|
62 |
+
</Tooltip>
|
63 |
+
</TooltipProvider>
|
64 |
+
)}
|
65 |
+
|
66 |
+
{/* Delete button */}
|
67 |
+
{onDelete && !message.isLoading && (
|
68 |
+
<TooltipProvider>
|
69 |
+
<Tooltip>
|
70 |
+
<TooltipTrigger asChild>
|
71 |
+
<div className="flex opacity-0 group-hover:opacity-100">
|
72 |
+
<Button
|
73 |
+
variant="ghost"
|
74 |
+
size="sm"
|
75 |
+
onClick={onDelete}
|
76 |
+
className="text-xs flex items-center gap-1.5 text-muted-foreground hover:text-destructive hover:bg-background/50 backdrop-blur-sm"
|
77 |
+
>
|
78 |
+
<Trash2 className="h-3 w-3" />
|
79 |
+
</Button>
|
80 |
+
</div>
|
81 |
+
</TooltipTrigger>
|
82 |
+
<TooltipContent>
|
83 |
+
<p>Delete message</p>
|
84 |
+
</TooltipContent>
|
85 |
+
</Tooltip>
|
86 |
+
</TooltipProvider>
|
87 |
+
)}
|
88 |
+
|
89 |
+
{/* Copy button */}
|
90 |
+
{onCopy && !message.isLoading && (
|
91 |
+
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
92 |
+
<Button variant="link" size="sm" onClick={onCopy}>
|
93 |
+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
94 |
+
</Button>
|
95 |
+
</div>
|
96 |
+
)}
|
97 |
+
</>
|
98 |
+
);
|
99 |
+
};
|
frontend/src/components/chat/MessageVariationControls.tsx
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React from 'react';
|
3 |
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
4 |
+
import { Button } from "@/components/ui/button";
|
5 |
+
import { Message } from "@/types/chat";
|
6 |
+
|
7 |
+
interface MessageVariationControlsProps {
|
8 |
+
message: Message;
|
9 |
+
onSelectVariation?: (messageId: string, variationId: string) => void;
|
10 |
+
}
|
11 |
+
|
12 |
+
export const MessageVariationControls: React.FC<MessageVariationControlsProps> = ({
|
13 |
+
message,
|
14 |
+
onSelectVariation
|
15 |
+
}) => {
|
16 |
+
if (!message.variations || message.variations.length <= 1 || !onSelectVariation) {
|
17 |
+
return null;
|
18 |
+
}
|
19 |
+
|
20 |
+
// Function to get the current variation index and navigate through variations
|
21 |
+
const navigateVariations = (direction: 'prev' | 'next') => {
|
22 |
+
const currentIndex = message.activeVariation
|
23 |
+
? message.variations!.findIndex(v => v.id === message.activeVariation)
|
24 |
+
: 0;
|
25 |
+
|
26 |
+
let newIndex;
|
27 |
+
if (direction === 'prev') {
|
28 |
+
newIndex = (currentIndex - 1 + message.variations!.length) % message.variations!.length;
|
29 |
+
} else {
|
30 |
+
newIndex = (currentIndex + 1) % message.variations!.length;
|
31 |
+
}
|
32 |
+
|
33 |
+
onSelectVariation(message.id, message.variations![newIndex].id);
|
34 |
+
};
|
35 |
+
|
36 |
+
// Get current variation index for display
|
37 |
+
const getCurrentVariationIndex = () => {
|
38 |
+
return message.activeVariation
|
39 |
+
? message.variations!.findIndex(v => v.id === message.activeVariation) + 1
|
40 |
+
: 1;
|
41 |
+
};
|
42 |
+
|
43 |
+
return (
|
44 |
+
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 rounded-md border shadow-sm">
|
45 |
+
<Button
|
46 |
+
variant="ghost"
|
47 |
+
size="sm"
|
48 |
+
onClick={() => navigateVariations('prev')}
|
49 |
+
className="h-6 w-6 p-0 flex items-center justify-center"
|
50 |
+
aria-label="Previous variation"
|
51 |
+
>
|
52 |
+
<ChevronLeft className="h-3 w-3" />
|
53 |
+
</Button>
|
54 |
+
<span className="text-xs px-1.5 font-medium">
|
55 |
+
{getCurrentVariationIndex()}/{message.variations.length}
|
56 |
+
</span>
|
57 |
+
<Button
|
58 |
+
variant="ghost"
|
59 |
+
size="sm"
|
60 |
+
onClick={() => navigateVariations('next')}
|
61 |
+
className="h-6 w-6 p-0 flex items-center justify-center"
|
62 |
+
aria-label="Next variation"
|
63 |
+
>
|
64 |
+
<ChevronRight className="h-3 w-3" />
|
65 |
+
</Button>
|
66 |
+
</div>
|
67 |
+
);
|
68 |
+
};
|
frontend/src/components/chat/ThinkingAnimation.tsx
CHANGED
@@ -1,49 +1,36 @@
|
|
1 |
|
|
|
2 |
import { useState, useEffect } from "react";
|
3 |
|
4 |
export const ThinkingAnimation = () => {
|
5 |
const [dots, setDots] = useState(0);
|
6 |
-
|
7 |
useEffect(() => {
|
8 |
const interval = setInterval(() => {
|
9 |
setDots((prev) => (prev + 1) % 4);
|
10 |
}, 400);
|
11 |
-
|
12 |
return () => clearInterval(interval);
|
13 |
}, []);
|
14 |
-
|
15 |
return (
|
16 |
-
<div className="flex
|
17 |
-
<div className="thinking-brain">
|
18 |
-
<
|
19 |
-
|
20 |
-
|
21 |
-
<
|
22 |
-
|
23 |
-
<path d="M6 12h4" className="stroke-financial-accent stroke-[1.5]" />
|
24 |
-
<path d="M14 12h4" className="stroke-financial-accent stroke-[1.5]" />
|
25 |
-
<path d="M13.5 8h1" className="stroke-financial-accent stroke-[1.5]" />
|
26 |
-
<path d="M16.5 8h1" className="stroke-financial-accent stroke-[1.5]" />
|
27 |
-
<path d="M13.5 16h1" className="stroke-financial-accent stroke-[1.5]" />
|
28 |
-
<path d="M16.5 16h1" className="stroke-financial-accent stroke-[1.5]" />
|
29 |
-
<path d="M9.5 8h1" className="stroke-financial-accent stroke-[1.5]" />
|
30 |
-
<path d="M6.5 8h1" className="stroke-financial-accent stroke-[1.5]" />
|
31 |
-
<path d="M9.5 16h1" className="stroke-financial-accent stroke-[1.5]" />
|
32 |
-
<path d="M6.5 16h1" className="stroke-financial-accent stroke-[1.5]" />
|
33 |
-
</svg>
|
34 |
-
<div className="thinking-waves">
|
35 |
-
<span></span>
|
36 |
-
<span></span>
|
37 |
-
<span></span>
|
38 |
</div>
|
39 |
</div>
|
40 |
-
|
|
|
41 |
Thinking
|
42 |
<span className="thinking-dots-container ml-2">
|
43 |
{Array(3).fill(0).map((_, i) => (
|
44 |
-
<span
|
45 |
-
key={i}
|
46 |
-
className={`thinking-dot ${i <= dots ? 'thinking-dot-active' : ''}`}
|
47 |
></span>
|
48 |
))}
|
49 |
</span>
|
|
|
1 |
|
2 |
+
import { Brain } from "lucide-react";
|
3 |
import { useState, useEffect } from "react";
|
4 |
|
5 |
export const ThinkingAnimation = () => {
|
6 |
const [dots, setDots] = useState(0);
|
7 |
+
|
8 |
useEffect(() => {
|
9 |
const interval = setInterval(() => {
|
10 |
setDots((prev) => (prev + 1) % 4);
|
11 |
}, 400);
|
12 |
+
|
13 |
return () => clearInterval(interval);
|
14 |
}, []);
|
15 |
+
|
16 |
return (
|
17 |
+
<div className="flex items-center gap-2 px-4 py-1">
|
18 |
+
<div className="thinking-brain-svg relative flex items-center justify-center">
|
19 |
+
<Brain className="h-4 w-4 text-financial-accent" />
|
20 |
+
<div className="thinking-waves-enhanced flex items-center justify-center absolute inset-0">
|
21 |
+
<span className="block absolute border-4 border-financial-accent/20 bg-financial-accent/20 rounded-full animate-[ripple_1.5s_ease-out_infinite]"></span>
|
22 |
+
<span className="block absolute border-4 border-financial-accent/30 bg-financial-accent/20 rounded-full animate-[ripple_1.5s_ease-out_0.4s_infinite]"></span>
|
23 |
+
<span className="block absolute border-4 border-financial-accent/40 bg-financial-accent/20 rounded-full animate-[ripple_1.5s_ease-out_0.8s_infinite]"></span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
</div>
|
25 |
</div>
|
26 |
+
|
27 |
+
<span className="text-sm font-medium text-financial-accent flex items-center">
|
28 |
Thinking
|
29 |
<span className="thinking-dots-container ml-2">
|
30 |
{Array(3).fill(0).map((_, i) => (
|
31 |
+
<span
|
32 |
+
key={i}
|
33 |
+
className={`thinking-dot ${i <= dots ? 'thinking-dot-active bg-financial-accent' : 'bg-financial-accent/30'}`}
|
34 |
></span>
|
35 |
))}
|
36 |
</span>
|
frontend/src/index.css
CHANGED
@@ -200,7 +200,8 @@
|
|
200 |
.message-bubble-user {
|
201 |
border-top-right-radius: 4px;
|
202 |
}
|
203 |
-
|
|
|
204 |
.thinking-brain {
|
205 |
position: relative;
|
206 |
width: 32px;
|
@@ -211,12 +212,20 @@
|
|
211 |
justify-content: center;
|
212 |
}
|
213 |
|
214 |
-
.thinking-brain-
|
215 |
-
z-index: 10;
|
216 |
position: relative;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
217 |
}
|
218 |
|
219 |
-
.thinking-waves {
|
220 |
position: absolute;
|
221 |
top: 0;
|
222 |
left: 0;
|
@@ -225,29 +234,30 @@
|
|
225 |
display: flex;
|
226 |
justify-content: center;
|
227 |
align-items: center;
|
|
|
228 |
}
|
229 |
|
230 |
-
.thinking-waves span {
|
231 |
position: absolute;
|
232 |
-
border:
|
233 |
border-radius: 50%;
|
234 |
opacity: 0;
|
235 |
-
animation: thinking-wave
|
236 |
}
|
237 |
|
238 |
-
.thinking-waves span:nth-child(1) {
|
239 |
width: 100%;
|
240 |
height: 100%;
|
241 |
animation-delay: 0s;
|
242 |
}
|
243 |
|
244 |
-
.thinking-waves span:nth-child(2) {
|
245 |
width: 80%;
|
246 |
height: 80%;
|
247 |
animation-delay: 0.3s;
|
248 |
}
|
249 |
|
250 |
-
.thinking-waves span:nth-child(3) {
|
251 |
width: 60%;
|
252 |
height: 60%;
|
253 |
animation-delay: 0.6s;
|
@@ -256,13 +266,13 @@
|
|
256 |
@keyframes thinking-wave {
|
257 |
0% {
|
258 |
transform: scale(0.5);
|
259 |
-
opacity: 0.
|
260 |
}
|
261 |
50% {
|
262 |
-
opacity: 0.
|
263 |
}
|
264 |
100% {
|
265 |
-
transform: scale(1.
|
266 |
opacity: 0;
|
267 |
}
|
268 |
}
|
@@ -286,6 +296,27 @@
|
|
286 |
opacity: 1;
|
287 |
}
|
288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
289 |
/* Variations UI */
|
290 |
.variations-container {
|
291 |
margin-top: 0.5rem;
|
@@ -441,7 +472,10 @@ pre code {
|
|
441 |
/* Chat bubble styles */
|
442 |
.thinking-dot {
|
443 |
@apply inline-block rounded-full bg-financial-accent;
|
444 |
-
|
|
|
|
|
|
|
445 |
}
|
446 |
|
447 |
@keyframes pulse {
|
@@ -466,7 +500,7 @@ pre code {
|
|
466 |
|
467 |
/* Think tag styling */
|
468 |
.think-block {
|
469 |
-
@apply bg-stone-50/70 dark:bg-stone-900/50
|
470 |
position: relative;
|
471 |
overflow: hidden;
|
472 |
backdrop-filter: blur(6px);
|
@@ -480,7 +514,6 @@ pre code {
|
|
480 |
height: 100%;
|
481 |
width: 4px;
|
482 |
background: linear-gradient(to bottom, theme('colors.financial.accent'), theme('colors.financial.light-accent'));
|
483 |
-
animation: pulse-border 2s infinite alternate;
|
484 |
}
|
485 |
|
486 |
.think-block::after {
|
@@ -489,7 +522,7 @@ pre code {
|
|
489 |
right: 10px;
|
490 |
bottom: 5px;
|
491 |
opacity: 0.2;
|
492 |
-
font-size:
|
493 |
}
|
494 |
|
495 |
/* Message variations styling */
|
@@ -529,4 +562,4 @@ pre code {
|
|
529 |
100% {
|
530 |
box-shadow: 0 0 0 0 rgba(var(--accent), 0);
|
531 |
}
|
532 |
-
}
|
|
|
200 |
.message-bubble-user {
|
201 |
border-top-right-radius: 4px;
|
202 |
}
|
203 |
+
|
204 |
+
/* Thinking animation - Enhanced */
|
205 |
.thinking-brain {
|
206 |
position: relative;
|
207 |
width: 32px;
|
|
|
212 |
justify-content: center;
|
213 |
}
|
214 |
|
215 |
+
.thinking-brain-small {
|
|
|
216 |
position: relative;
|
217 |
+
width: 20px;
|
218 |
+
height: 20px;
|
219 |
+
display: flex;
|
220 |
+
align-items: center;
|
221 |
+
justify-content: center;
|
222 |
+
}
|
223 |
+
|
224 |
+
.thinking-brain-svg {
|
225 |
+
z-index: 2;
|
226 |
}
|
227 |
|
228 |
+
.thinking-waves-enhanced {
|
229 |
position: absolute;
|
230 |
top: 0;
|
231 |
left: 0;
|
|
|
234 |
display: flex;
|
235 |
justify-content: center;
|
236 |
align-items: center;
|
237 |
+
z-index: 1;
|
238 |
}
|
239 |
|
240 |
+
.thinking-waves-enhanced span {
|
241 |
position: absolute;
|
242 |
+
border: 1.5px solid var(--financial-accent, #7C3AED);
|
243 |
border-radius: 50%;
|
244 |
opacity: 0;
|
245 |
+
animation: thinking-wave 2.5s ease-out infinite;
|
246 |
}
|
247 |
|
248 |
+
.thinking-waves-enhanced span:nth-child(1) {
|
249 |
width: 100%;
|
250 |
height: 100%;
|
251 |
animation-delay: 0s;
|
252 |
}
|
253 |
|
254 |
+
.thinking-waves-enhanced span:nth-child(2) {
|
255 |
width: 80%;
|
256 |
height: 80%;
|
257 |
animation-delay: 0.3s;
|
258 |
}
|
259 |
|
260 |
+
.thinking-waves-enhanced span:nth-child(3) {
|
261 |
width: 60%;
|
262 |
height: 60%;
|
263 |
animation-delay: 0.6s;
|
|
|
266 |
@keyframes thinking-wave {
|
267 |
0% {
|
268 |
transform: scale(0.5);
|
269 |
+
opacity: 0.3;
|
270 |
}
|
271 |
50% {
|
272 |
+
opacity: 0.7;
|
273 |
}
|
274 |
100% {
|
275 |
+
transform: scale(1.4);
|
276 |
opacity: 0;
|
277 |
}
|
278 |
}
|
|
|
296 |
opacity: 1;
|
297 |
}
|
298 |
|
299 |
+
/* Collapsible thinking block */
|
300 |
+
.think-collapsible {
|
301 |
+
@apply bg-financial-accent/5 dark:bg-financial-accent/10 rounded-md overflow-hidden;
|
302 |
+
transition: all 0.3s ease;
|
303 |
+
border-left: 3px solid var(--financial-accent, #7C3AED);
|
304 |
+
background: linear-gradient(to right, rgba(124, 58, 237, 0.05), transparent);
|
305 |
+
}
|
306 |
+
|
307 |
+
.think-collapsible:hover {
|
308 |
+
background: linear-gradient(to right, rgba(124, 58, 237, 0.1), rgba(124, 58, 237, 0.02));
|
309 |
+
}
|
310 |
+
|
311 |
+
.think-header {
|
312 |
+
@apply px-3 py-2 text-financial-accent;
|
313 |
+
}
|
314 |
+
|
315 |
+
.think-block {
|
316 |
+
@apply bg-financial-accent/5 dark:bg-financial-accent/10 p-3 rounded-md text-muted-foreground;
|
317 |
+
position: relative;
|
318 |
+
}
|
319 |
+
|
320 |
/* Variations UI */
|
321 |
.variations-container {
|
322 |
margin-top: 0.5rem;
|
|
|
472 |
/* Chat bubble styles */
|
473 |
.thinking-dot {
|
474 |
@apply inline-block rounded-full bg-financial-accent;
|
475 |
+
}
|
476 |
+
|
477 |
+
.thinking-dot-active {
|
478 |
+
@apply animate-pulse;
|
479 |
}
|
480 |
|
481 |
@keyframes pulse {
|
|
|
500 |
|
501 |
/* Think tag styling */
|
502 |
.think-block {
|
503 |
+
@apply bg-stone-50/70 dark:bg-stone-900/50 p-3 my-2 rounded-md italic text-muted-foreground;
|
504 |
position: relative;
|
505 |
overflow: hidden;
|
506 |
backdrop-filter: blur(6px);
|
|
|
514 |
height: 100%;
|
515 |
width: 4px;
|
516 |
background: linear-gradient(to bottom, theme('colors.financial.accent'), theme('colors.financial.light-accent'));
|
|
|
517 |
}
|
518 |
|
519 |
.think-block::after {
|
|
|
522 |
right: 10px;
|
523 |
bottom: 5px;
|
524 |
opacity: 0.2;
|
525 |
+
font-size: 2rem;
|
526 |
}
|
527 |
|
528 |
/* Message variations styling */
|
|
|
562 |
100% {
|
563 |
box-shadow: 0 0 0 0 rgba(var(--accent), 0);
|
564 |
}
|
565 |
+
}
|
frontend/src/types/chat.ts
CHANGED
@@ -1,4 +1,11 @@
|
|
1 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
export interface Message {
|
3 |
id: string;
|
4 |
content: string;
|
@@ -7,8 +14,10 @@ export interface Message {
|
|
7 |
isLoading?: boolean;
|
8 |
error?: boolean;
|
9 |
result?: any;
|
10 |
-
variations?:
|
11 |
activeVariation?: string;
|
|
|
|
|
12 |
}
|
13 |
|
14 |
export interface Chat {
|
|
|
1 |
|
2 |
+
export interface MessageVariation {
|
3 |
+
id: string;
|
4 |
+
content: string;
|
5 |
+
timestamp: Date;
|
6 |
+
childMessages?: Message[]; // Messages that belong to this variation
|
7 |
+
}
|
8 |
+
|
9 |
export interface Message {
|
10 |
id: string;
|
11 |
content: string;
|
|
|
14 |
isLoading?: boolean;
|
15 |
error?: boolean;
|
16 |
result?: any;
|
17 |
+
variations?: MessageVariation[];
|
18 |
activeVariation?: string;
|
19 |
+
parentMessageId?: string; // Link to parent message if this is a child message
|
20 |
+
variationId?: string; // Link to the variation it belongs to
|
21 |
}
|
22 |
|
23 |
export interface Chat {
|