Spaces:
Sleeping
Sleeping
Chandima Prabhath
commited on
Commit
·
1904e4c
1
Parent(s):
22b1735
feat: Add chat components and modals for enhanced user interaction
Browse files- Implement DeleteChatDialog for confirming chat deletions.
- Create ThinkingAnimation component to indicate processing state.
- Develop WelcomeScreen to greet users and initiate new chats.
- Establish ChatLayout to manage chat interface and settings modals.
- Introduce ProfileModal for user profile management with avatar support.
- Add SettingsModal for configuring application settings and preferences.
- Create SourcesModal for displaying and filtering knowledge sources.
- Enhance apiService for querying and managing data interactions.
- Define chat and message types for structured data handling.
- frontend/README.md +5 -0
- frontend/index.html +5 -4
- frontend/package.json +1 -0
- frontend/src/App.tsx +3 -8
- frontend/src/components/chat/ChatBubble.tsx +238 -80
- frontend/src/components/chat/ChatInputArea.tsx +60 -0
- frontend/src/{pages/HomePage.tsx → components/chat/ChatInterface.tsx} +314 -333
- frontend/src/components/chat/ChatMessage.tsx +16 -6
- frontend/src/components/chat/ChatSidebar.tsx +168 -0
- frontend/src/components/chat/DeleteChatDialog.tsx +46 -0
- frontend/src/components/chat/ThinkingAnimation.tsx +53 -0
- frontend/src/components/chat/WelcomeScreen.tsx +25 -0
- frontend/src/components/layout/ChatLayout.tsx +31 -0
- frontend/src/components/layout/MainLayout.tsx +0 -24
- frontend/src/components/layout/ModeToggle.tsx +7 -10
- frontend/src/components/modals/ProfileModal.tsx +223 -0
- frontend/src/{pages/SettingsPage.tsx → components/modals/SettingsModal.tsx} +165 -182
- frontend/src/components/modals/SourcesModal.tsx +301 -0
- frontend/src/index.css +234 -1
- frontend/src/lib/storage.ts +44 -7
- frontend/src/pages/Index.tsx +0 -8
- frontend/src/pages/SourcesPage.tsx +0 -286
- frontend/src/services/{rulingService.ts → apiService.ts} +17 -9
- frontend/src/types/chat.ts +20 -0
frontend/README.md
CHANGED
@@ -71,3 +71,8 @@ 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)
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
frontend/index.html
CHANGED
@@ -1,14 +1,15 @@
|
|
|
|
1 |
<!DOCTYPE html>
|
2 |
<html lang="en">
|
3 |
<head>
|
4 |
<meta charset="UTF-8" />
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
6 |
-
<title>
|
7 |
-
<meta name="description" content="
|
8 |
<meta name="author" content="Lovable" />
|
9 |
|
10 |
-
<meta property="og:title" content="
|
11 |
-
<meta property="og:description" content="
|
12 |
<meta property="og:type" content="website" />
|
13 |
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
14 |
|
|
|
1 |
+
|
2 |
<!DOCTYPE html>
|
3 |
<html lang="en">
|
4 |
<head>
|
5 |
<meta charset="UTF-8" />
|
6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>Insight AI - Chat Assistant</title>
|
8 |
+
<meta name="description" content="AI-powered chat assistant with RAG capabilities" />
|
9 |
<meta name="author" content="Lovable" />
|
10 |
|
11 |
+
<meta property="og:title" content="Insight AI" />
|
12 |
+
<meta property="og:description" content="AI-powered chat assistant with RAG capabilities" />
|
13 |
<meta property="og:type" content="website" />
|
14 |
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
15 |
|
frontend/package.json
CHANGED
@@ -12,6 +12,7 @@
|
|
12 |
},
|
13 |
"dependencies": {
|
14 |
"@hookform/resolvers": "^3.9.0",
|
|
|
15 |
"@radix-ui/react-accordion": "^1.2.0",
|
16 |
"@radix-ui/react-alert-dialog": "^1.1.1",
|
17 |
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
|
|
12 |
},
|
13 |
"dependencies": {
|
14 |
"@hookform/resolvers": "^3.9.0",
|
15 |
+
"@multiavatar/multiavatar": "^1.0.7",
|
16 |
"@radix-ui/react-accordion": "^1.2.0",
|
17 |
"@radix-ui/react-alert-dialog": "^1.1.1",
|
18 |
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
frontend/src/App.tsx
CHANGED
@@ -5,10 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|
5 |
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
6 |
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
7 |
import { useEffect } from "react";
|
8 |
-
import
|
9 |
-
import HomePage from "@/pages/HomePage";
|
10 |
-
import SourcesPage from "@/pages/SourcesPage";
|
11 |
-
import SettingsPage from "@/pages/SettingsPage";
|
12 |
import NotFound from "@/pages/NotFound";
|
13 |
|
14 |
const queryClient = new QueryClient({
|
@@ -23,7 +20,7 @@ const queryClient = new QueryClient({
|
|
23 |
const App = () => {
|
24 |
// Set the window title
|
25 |
useEffect(() => {
|
26 |
-
document.title = "
|
27 |
}, []);
|
28 |
|
29 |
return (
|
@@ -33,9 +30,7 @@ const App = () => {
|
|
33 |
<Sonner />
|
34 |
<BrowserRouter>
|
35 |
<Routes>
|
36 |
-
<Route path="/" element={<
|
37 |
-
<Route path="/sources" element={<MainLayout><SourcesPage /></MainLayout>} />
|
38 |
-
<Route path="/settings" element={<MainLayout><SettingsPage /></MainLayout>} />
|
39 |
<Route path="*" element={<NotFound />} />
|
40 |
</Routes>
|
41 |
</BrowserRouter>
|
|
|
5 |
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
6 |
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
7 |
import { useEffect } from "react";
|
8 |
+
import ChatLayout from "@/components/layout/ChatLayout";
|
|
|
|
|
|
|
9 |
import NotFound from "@/pages/NotFound";
|
10 |
|
11 |
const queryClient = new QueryClient({
|
|
|
20 |
const App = () => {
|
21 |
// Set the window title
|
22 |
useEffect(() => {
|
23 |
+
document.title = "Insight AI - Chat Assistant";
|
24 |
}, []);
|
25 |
|
26 |
return (
|
|
|
30 |
<Sonner />
|
31 |
<BrowserRouter>
|
32 |
<Routes>
|
33 |
+
<Route path="/" element={<ChatLayout />} />
|
|
|
|
|
34 |
<Route path="*" element={<NotFound />} />
|
35 |
</Routes>
|
36 |
</BrowserRouter>
|
frontend/src/components/chat/ChatBubble.tsx
CHANGED
@@ -1,12 +1,22 @@
|
|
1 |
|
2 |
import { useState } from "react";
|
3 |
-
import { ArrowRight, RefreshCcw, Copy, Check } from "lucide-react";
|
4 |
import { Button } from "@/components/ui/button";
|
5 |
import { cn } from "@/lib/utils";
|
6 |
import { format } from "date-fns";
|
7 |
import { Avatar, AvatarFallback } from "../ui/avatar";
|
8 |
import { ChatMessage } from "./ChatMessage";
|
9 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
|
11 |
interface Message {
|
12 |
id: string;
|
@@ -16,106 +26,254 @@ interface Message {
|
|
16 |
isLoading?: boolean;
|
17 |
error?: boolean;
|
18 |
result?: any;
|
|
|
|
|
19 |
}
|
20 |
|
21 |
interface ChatBubbleProps {
|
22 |
message: Message;
|
23 |
onViewSearchResults?: (messageId: string) => void;
|
24 |
onRetry?: (messageId: string) => void;
|
|
|
|
|
|
|
25 |
}
|
26 |
|
27 |
-
|
28 |
-
|
29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
const isSystem = message.sender === "system";
|
31 |
-
const showCopyButton = true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
const copyToClipboard = () => {
|
33 |
-
navigator.clipboard.writeText(
|
34 |
-
setCopied(true)
|
35 |
-
toast.success('Copied to clipboard!')
|
36 |
-
setTimeout(() => setCopied(false), 2000)
|
37 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
return (
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
<Avatar className={cn(
|
51 |
-
"h-8 w-8 border",
|
52 |
-
isSystem
|
53 |
-
? "bg-financial-accent text-white shadow-lg"
|
54 |
-
: "bg-muted"
|
55 |
)}>
|
56 |
-
<
|
57 |
-
|
58 |
-
</AvatarFallback>
|
59 |
-
</Avatar>
|
60 |
-
|
61 |
-
<div className="flex flex-col">
|
62 |
-
<div className={cn(
|
63 |
-
"rounded-2xl shadow-lg message-bubble backdrop-blur-sm",
|
64 |
isSystem
|
65 |
-
? "bg-
|
66 |
-
: "bg-
|
67 |
-
message.error && "border-destructive dark:border-red-500"
|
68 |
)}>
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
<span></span>
|
74 |
-
</div>
|
75 |
-
) : (
|
76 |
-
<ChatMessage
|
77 |
-
content={message.content}
|
78 |
-
/>
|
79 |
-
)}
|
80 |
|
81 |
-
|
82 |
-
{/* Chat bubble footer */}
|
83 |
-
<div className="flex flex-row chat-bubble-footer justify-between">
|
84 |
-
{/* Time */}
|
85 |
<div className={cn(
|
86 |
-
"
|
87 |
-
isSystem
|
|
|
|
|
|
|
88 |
)}>
|
89 |
-
{
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
<div className="flex justify-end mt-1">
|
96 |
-
<Button
|
97 |
-
variant="secondary"
|
98 |
-
size="sm"
|
99 |
-
onClick={() => onRetry(message.id)}
|
100 |
-
className="text-xs flex items-center gap-1.5 text-muted-foreground hover:border border-financial-accent/30 bg-background/50 backdrop-blur-sm"
|
101 |
-
>
|
102 |
-
<RefreshCcw className="h-3 w-3" />
|
103 |
-
Retry
|
104 |
-
</Button>
|
105 |
-
</div>
|
106 |
-
)}
|
107 |
-
{/* Copy */}
|
108 |
-
{showCopyButton && (
|
109 |
-
<div className="relative top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
110 |
-
<Button variant="link" size="sm" onClick={copyToClipboard}>
|
111 |
-
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
112 |
-
</Button>
|
113 |
-
</div>
|
114 |
)}
|
115 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
116 |
</div>
|
117 |
</div>
|
118 |
</div>
|
119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
120 |
);
|
121 |
};
|
|
|
1 |
|
2 |
import { useState } from "react";
|
3 |
+
import { ArrowRight, RefreshCcw, Copy, Check, Trash2, RotateCcw, ListFilter, ChevronLeft, ChevronRight } from "lucide-react";
|
4 |
import { Button } from "@/components/ui/button";
|
5 |
import { cn } from "@/lib/utils";
|
6 |
import { format } from "date-fns";
|
7 |
import { Avatar, AvatarFallback } from "../ui/avatar";
|
8 |
import { ChatMessage } from "./ChatMessage";
|
9 |
+
import { ThinkingAnimation } from "./ThinkingAnimation";
|
10 |
+
import { toast } from '../ui/sonner';
|
11 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
12 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
13 |
+
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
14 |
+
|
15 |
+
interface MessageVariation {
|
16 |
+
id: string;
|
17 |
+
content: string;
|
18 |
+
timestamp: Date;
|
19 |
+
}
|
20 |
|
21 |
interface Message {
|
22 |
id: string;
|
|
|
26 |
isLoading?: boolean;
|
27 |
error?: boolean;
|
28 |
result?: any;
|
29 |
+
variations?: MessageVariation[];
|
30 |
+
activeVariation?: string;
|
31 |
}
|
32 |
|
33 |
interface ChatBubbleProps {
|
34 |
message: Message;
|
35 |
onViewSearchResults?: (messageId: string) => void;
|
36 |
onRetry?: (messageId: string) => void;
|
37 |
+
onRegenerate?: (messageId: string) => void;
|
38 |
+
onDelete?: (messageId: string) => void;
|
39 |
+
onSelectVariation?: (messageId: string, variationId: string) => void;
|
40 |
}
|
41 |
|
42 |
+
export const ChatBubble = ({
|
43 |
+
message,
|
44 |
+
onViewSearchResults,
|
45 |
+
onRetry,
|
46 |
+
onRegenerate,
|
47 |
+
onDelete,
|
48 |
+
onSelectVariation
|
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
|
58 |
+
const displayContent = isSystem && hasVariations && message.activeVariation
|
59 |
+
? message.variations.find(v => v.id === message.activeVariation)?.content || message.content
|
60 |
+
: message.content;
|
61 |
+
|
62 |
const copyToClipboard = () => {
|
63 |
+
navigator.clipboard.writeText(displayContent);
|
64 |
+
setCopied(true);
|
65 |
+
toast.success('Copied to clipboard!');
|
66 |
+
setTimeout(() => setCopied(false), 2000);
|
67 |
+
};
|
68 |
+
|
69 |
+
const handleDelete = () => {
|
70 |
+
setDeleteDialogOpen(false);
|
71 |
+
if (onDelete) {
|
72 |
+
onDelete(message.id);
|
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-4",
|
113 |
+
isSystem ? "justify-start" : "justify-end"
|
114 |
+
)}
|
115 |
+
>
|
116 |
+
<div className={cn(
|
117 |
+
"flex gap-3 max-w-[80%]",
|
118 |
+
isSystem ? "flex-row" : "flex-row-reverse"
|
|
|
|
|
|
|
|
|
|
|
119 |
)}>
|
120 |
+
<Avatar className={cn(
|
121 |
+
"h-8 w-8 border",
|
|
|
|
|
|
|
|
|
|
|
|
|
122 |
isSystem
|
123 |
+
? "bg-financial-accent dark:text-white shadow-lg"
|
124 |
+
: "bg-muted"
|
|
|
125 |
)}>
|
126 |
+
<AvatarFallback className="text-xs font-semibold">
|
127 |
+
{isSystem ? "AI" : "You"}
|
128 |
+
</AvatarFallback>
|
129 |
+
</Avatar>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
130 |
|
131 |
+
<div className="flex flex-col">
|
|
|
|
|
|
|
132 |
<div className={cn(
|
133 |
+
"rounded-2xl shadow-lg message-bubble backdrop-blur-sm",
|
134 |
+
isSystem
|
135 |
+
? "bg-white/90 dark:bg-card/90 border border-border text-foreground message-bubble-ai"
|
136 |
+
: "bg-financial-accent/30 border border-financial-accent/30 dark:text-white message-bubble-user",
|
137 |
+
message.error && "border-destructive dark:border-red-500"
|
138 |
)}>
|
139 |
+
{message.isLoading ? (
|
140 |
+
<ThinkingAnimation />
|
141 |
+
) : (
|
142 |
+
<ChatMessage
|
143 |
+
content={displayContent}
|
144 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
145 |
)}
|
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 mt-1",
|
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 |
+
{/* Retry button for failed messages */}
|
187 |
+
{message.error && onRetry && (
|
188 |
+
<div className="flex mt-1">
|
189 |
+
<Button
|
190 |
+
variant="secondary"
|
191 |
+
size="sm"
|
192 |
+
onClick={() => onRetry(message.id)}
|
193 |
+
className="text-xs flex items-center gap-1.5 text-muted-foreground hover:border border-financial-accent/30 bg-background/50 backdrop-blur-sm"
|
194 |
+
>
|
195 |
+
<RefreshCcw className="h-3 w-3" />
|
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 |
+
{/* Copy */}
|
247 |
+
{showCopyButton && !message.isLoading && (
|
248 |
+
<div className="relative top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
249 |
+
<Button variant="link" size="sm" onClick={copyToClipboard}>
|
250 |
+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
251 |
+
</Button>
|
252 |
+
</div>
|
253 |
+
)}
|
254 |
+
</div>}
|
255 |
+
</div>
|
256 |
</div>
|
257 |
</div>
|
258 |
</div>
|
259 |
+
|
260 |
+
{/* Delete confirmation dialog */}
|
261 |
+
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
262 |
+
<AlertDialogContent>
|
263 |
+
<AlertDialogHeader>
|
264 |
+
<AlertDialogTitle>Delete Message</AlertDialogTitle>
|
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 |
};
|
frontend/src/components/chat/ChatInputArea.tsx
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React, { RefObject } from "react";
|
3 |
+
import { CornerDownLeft } from "lucide-react";
|
4 |
+
import { Input } from "@/components/ui/input";
|
5 |
+
import { Button } from "@/components/ui/button";
|
6 |
+
|
7 |
+
interface ChatInputAreaProps {
|
8 |
+
inputRef: RefObject<HTMLInputElement>;
|
9 |
+
inputValue: string;
|
10 |
+
setInputValue: (value: string) => void;
|
11 |
+
handleSendMessage: (e?: React.FormEvent) => void;
|
12 |
+
isLoading: boolean;
|
13 |
+
}
|
14 |
+
|
15 |
+
export const ChatInputArea: React.FC<ChatInputAreaProps> = ({
|
16 |
+
inputRef,
|
17 |
+
inputValue,
|
18 |
+
setInputValue,
|
19 |
+
handleSendMessage,
|
20 |
+
isLoading
|
21 |
+
}) => {
|
22 |
+
return (
|
23 |
+
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background/80 dark:bg-background/50 backdrop-blur-md border-t border-border/30">
|
24 |
+
<div className="max-w-3xl mx-auto">
|
25 |
+
<form onSubmit={handleSendMessage} className="relative">
|
26 |
+
<div className="relative">
|
27 |
+
<Input
|
28 |
+
ref={inputRef}
|
29 |
+
type="text"
|
30 |
+
placeholder="Message Insight AI..."
|
31 |
+
value={inputValue}
|
32 |
+
onChange={(e) => setInputValue(e.target.value)}
|
33 |
+
className="pr-20 py-6 text-base bg-background/50 border border-border/50 focus-visible:ring-1 focus-visible:ring-primary/50 rounded-xl"
|
34 |
+
disabled={isLoading}
|
35 |
+
/>
|
36 |
+
|
37 |
+
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
38 |
+
<Button
|
39 |
+
type="submit"
|
40 |
+
size="icon"
|
41 |
+
className="rounded-lg"
|
42 |
+
disabled={isLoading || !inputValue.trim()}
|
43 |
+
>
|
44 |
+
{isLoading ? (
|
45 |
+
<div className="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
46 |
+
) : (
|
47 |
+
<CornerDownLeft className="h-4 w-4" />
|
48 |
+
)}
|
49 |
+
</Button>
|
50 |
+
</div>
|
51 |
+
</div>
|
52 |
+
|
53 |
+
<div className="mt-2 text-center text-xs text-muted-foreground">
|
54 |
+
Insight AI may produce inaccurate information. Verify important information.
|
55 |
+
</div>
|
56 |
+
</form>
|
57 |
+
</div>
|
58 |
+
</div>
|
59 |
+
);
|
60 |
+
};
|
frontend/src/{pages/HomePage.tsx → components/chat/ChatInterface.tsx}
RENAMED
@@ -1,48 +1,29 @@
|
|
1 |
-
|
2 |
import { useState, useRef, useEffect } from "react";
|
3 |
-
import {
|
4 |
-
import { Send, Plus, CornerDownLeft, TrashIcon, RefreshCcw, Bot, Sparkles, Menu } from "lucide-react";
|
5 |
import { Button } from "@/components/ui/button";
|
6 |
-
import {
|
7 |
-
import { rulingService, Message as APIMessage } from "@/services/rulingService";
|
8 |
import { toast } from "@/components/ui/sonner";
|
9 |
import { ChatBubble } from "@/components/chat/ChatBubble";
|
10 |
import { Separator } from "@/components/ui/separator";
|
11 |
import { cn } from "@/lib/utils";
|
12 |
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
13 |
-
import {
|
14 |
-
import {
|
15 |
-
import {
|
16 |
-
import {
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
isLoading?: boolean;
|
24 |
-
error?: boolean;
|
25 |
-
result?: any;
|
26 |
-
}
|
27 |
-
|
28 |
-
interface Chat {
|
29 |
-
id: string;
|
30 |
-
title: string;
|
31 |
-
messages: Message[];
|
32 |
-
createdAt: Date;
|
33 |
-
updatedAt: Date;
|
34 |
}
|
35 |
|
36 |
-
const WELCOME_MESSAGE = "Hello! I'm Insight AI
|
37 |
|
38 |
const generateId = () => Math.random().toString(36).substring(2, 11);
|
39 |
|
40 |
-
const
|
41 |
-
{ name: "Conversations", path: "/", icon: MessageCircle },
|
42 |
-
{ name: "Sources", path: "/sources", icon: FileText }
|
43 |
-
];
|
44 |
-
|
45 |
-
const HomePage = () => {
|
46 |
const [chats, setChats] = useState<Chat[]>(() => {
|
47 |
const savedChats = storage.get<Chat[]>(STORAGE_KEYS.CHATS);
|
48 |
if (savedChats) {
|
@@ -69,32 +50,15 @@ const HomePage = () => {
|
|
69 |
if (chats.length > 0) {
|
70 |
return chats[0];
|
71 |
}
|
72 |
-
|
73 |
-
// Create initial chat if none exists
|
74 |
-
const initialChat: Chat = {
|
75 |
-
id: generateId(),
|
76 |
-
title: "New Chat",
|
77 |
-
messages: [
|
78 |
-
{
|
79 |
-
id: generateId(),
|
80 |
-
content: WELCOME_MESSAGE,
|
81 |
-
sender: "system" as const,
|
82 |
-
timestamp: new Date()
|
83 |
-
}
|
84 |
-
],
|
85 |
-
createdAt: new Date(),
|
86 |
-
updatedAt: new Date()
|
87 |
-
};
|
88 |
-
|
89 |
-
return initialChat;
|
90 |
});
|
91 |
|
92 |
const [inputValue, setInputValue] = useState("");
|
93 |
const [isLoading, setIsLoading] = useState(false);
|
94 |
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
95 |
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
|
|
|
|
96 |
|
97 |
-
const navigate = useNavigate();
|
98 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
99 |
const inputRef = useRef<HTMLInputElement>(null);
|
100 |
|
@@ -112,9 +76,6 @@ const HomePage = () => {
|
|
112 |
: chats;
|
113 |
|
114 |
storage.set(STORAGE_KEYS.CHATS, allChats);
|
115 |
-
|
116 |
-
// Dispatch a custom event to notify storage updates
|
117 |
-
window.dispatchEvent(new Event("storage-updated"));
|
118 |
}, [chats, activeChat]);
|
119 |
|
120 |
const scrollToBottom = () => {
|
@@ -134,63 +95,21 @@ const HomePage = () => {
|
|
134 |
}
|
135 |
}, [isLoading]);
|
136 |
|
137 |
-
//
|
138 |
useEffect(() => {
|
139 |
-
const handleNewChat = () =>
|
140 |
-
const newChat: Chat = {
|
141 |
-
id: generateId(),
|
142 |
-
title: "New Chat",
|
143 |
-
messages: [
|
144 |
-
{
|
145 |
-
id: generateId(),
|
146 |
-
content: WELCOME_MESSAGE,
|
147 |
-
sender: "system" as const,
|
148 |
-
timestamp: new Date()
|
149 |
-
}
|
150 |
-
],
|
151 |
-
createdAt: new Date(),
|
152 |
-
updatedAt: new Date()
|
153 |
-
};
|
154 |
-
|
155 |
-
setActiveChat(newChat);
|
156 |
-
setChats(prev => [newChat, ...prev]);
|
157 |
-
setIsSidebarOpen(false);
|
158 |
-
setTimeout(() => {
|
159 |
-
inputRef.current?.focus();
|
160 |
-
}, 100);
|
161 |
-
};
|
162 |
-
|
163 |
const handleSelectChat = (e: Event) => {
|
164 |
const customEvent = e as CustomEvent;
|
165 |
const chatId = customEvent.detail?.chatId;
|
166 |
if (chatId) {
|
167 |
-
|
168 |
-
if (selectedChat) {
|
169 |
-
setActiveChat(selectedChat);
|
170 |
-
setIsSidebarOpen(false);
|
171 |
-
setTimeout(() => {
|
172 |
-
inputRef.current?.focus();
|
173 |
-
}, 100);
|
174 |
-
}
|
175 |
}
|
176 |
};
|
177 |
-
|
178 |
const handleDeleteChat = (e: Event) => {
|
179 |
const customEvent = e as CustomEvent;
|
180 |
const chatId = customEvent.detail?.chatId;
|
181 |
if (chatId) {
|
182 |
-
|
183 |
-
setChats(updatedChats);
|
184 |
-
|
185 |
-
// If we're deleting the active chat, switch to another one
|
186 |
-
if (activeChat?.id === chatId) {
|
187 |
-
setActiveChat(updatedChats.length > 0 ? updatedChats[0] : null);
|
188 |
-
|
189 |
-
// If no chats left, create a new one
|
190 |
-
if (updatedChats.length === 0) {
|
191 |
-
handleNewChat();
|
192 |
-
}
|
193 |
-
}
|
194 |
}
|
195 |
};
|
196 |
|
@@ -206,25 +125,19 @@ const HomePage = () => {
|
|
206 |
}, [chats, activeChat]);
|
207 |
|
208 |
const generateChatTitle = async (query: string) => {
|
209 |
-
if (!activeChat) return;
|
210 |
-
|
211 |
-
// Only generate titles for new chats
|
212 |
-
if (activeChat.title !== "New Chat") return;
|
213 |
|
214 |
setIsGeneratingTitle(true);
|
215 |
|
216 |
try {
|
217 |
-
const response = await
|
218 |
|
219 |
if (response.title) {
|
220 |
-
// Update the
|
221 |
setActiveChat(prevChat => {
|
222 |
if (!prevChat) return null;
|
223 |
|
224 |
-
const updatedChat = {
|
225 |
-
...prevChat,
|
226 |
-
title: response.title
|
227 |
-
};
|
228 |
|
229 |
// Update chats list
|
230 |
setChats(prevChats =>
|
@@ -238,7 +151,7 @@ const HomePage = () => {
|
|
238 |
}
|
239 |
} catch (error) {
|
240 |
console.error("Error generating chat title:", error);
|
241 |
-
// Fallback to using query as title
|
242 |
if (activeChat.title === "New Chat") {
|
243 |
setActiveChat(prevChat => {
|
244 |
if (!prevChat) return null;
|
@@ -248,7 +161,6 @@ const HomePage = () => {
|
|
248 |
title: query.slice(0, 30) + (query.length > 30 ? '...' : '')
|
249 |
};
|
250 |
|
251 |
-
// Update chats list
|
252 |
setChats(prevChats =>
|
253 |
prevChats.map(chat =>
|
254 |
chat.id === updatedChat.id ? updatedChat : chat
|
@@ -297,14 +209,14 @@ const HomePage = () => {
|
|
297 |
try {
|
298 |
// Prepare chat history for the API
|
299 |
const chatHistory: APIMessage[] = updatedChat.messages
|
300 |
-
.filter(msg => !msg.isLoading && msg.content) // filter out loading messages
|
301 |
.slice(0, -1) // exclude the loading message we just added
|
302 |
.map(msg => ({
|
303 |
role: msg.sender === "user" ? "user" : "assistant",
|
304 |
content: msg.content
|
305 |
}));
|
306 |
|
307 |
-
const response = await
|
308 |
query: userMessage.content,
|
309 |
chat_history: chatHistory
|
310 |
});
|
@@ -341,7 +253,7 @@ const HomePage = () => {
|
|
341 |
}
|
342 |
|
343 |
} catch (error) {
|
344 |
-
console.error("Error querying
|
345 |
|
346 |
// Replace loading message with error
|
347 |
const updatedMessages = updatedChat.messages.map(msg =>
|
@@ -370,20 +282,13 @@ const HomePage = () => {
|
|
370 |
}
|
371 |
};
|
372 |
|
373 |
-
const
|
374 |
-
const message = activeChat?.messages.find(msg => msg.id === messageId);
|
375 |
-
if (message && message.content) {
|
376 |
-
navigate(`/sources`);
|
377 |
-
}
|
378 |
-
};
|
379 |
-
|
380 |
-
const handleNewChat = () => {
|
381 |
const newChat: Chat = {
|
382 |
id: generateId(),
|
383 |
title: "New Chat",
|
384 |
messages: [
|
385 |
{
|
386 |
-
id:
|
387 |
content: WELCOME_MESSAGE,
|
388 |
sender: "system" as const,
|
389 |
timestamp: new Date()
|
@@ -395,11 +300,13 @@ const HomePage = () => {
|
|
395 |
|
396 |
setActiveChat(newChat);
|
397 |
setChats(prev => [newChat, ...prev]);
|
398 |
-
|
|
|
|
|
399 |
setIsSidebarOpen(false);
|
400 |
};
|
401 |
|
402 |
-
const
|
403 |
const selectedChat = chats.find(chat => chat.id === chatId);
|
404 |
if (selectedChat) {
|
405 |
setActiveChat(selectedChat);
|
@@ -410,9 +317,7 @@ const HomePage = () => {
|
|
410 |
}
|
411 |
};
|
412 |
|
413 |
-
const
|
414 |
-
e.stopPropagation();
|
415 |
-
|
416 |
const updatedChats = chats.filter(chat => chat.id !== chatId);
|
417 |
setChats(updatedChats);
|
418 |
|
@@ -422,9 +327,214 @@ const HomePage = () => {
|
|
422 |
|
423 |
// If no chats left, create a new one
|
424 |
if (updatedChats.length === 0) {
|
425 |
-
|
426 |
}
|
427 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
428 |
};
|
429 |
|
430 |
const handleRetryMessage = (messageId: string) => {
|
@@ -474,7 +584,7 @@ const HomePage = () => {
|
|
474 |
}));
|
475 |
|
476 |
// Retry the query
|
477 |
-
|
478 |
query: userMessageContent,
|
479 |
chat_history: chatHistory
|
480 |
})
|
@@ -494,8 +604,6 @@ const HomePage = () => {
|
|
494 |
};
|
495 |
|
496 |
setActiveChat(finalChat);
|
497 |
-
|
498 |
-
// Update chats list
|
499 |
setChats(prevChats =>
|
500 |
prevChats.map(chat =>
|
501 |
chat.id === finalChat.id ? finalChat : chat
|
@@ -528,217 +636,90 @@ const HomePage = () => {
|
|
528 |
});
|
529 |
};
|
530 |
|
531 |
-
const
|
532 |
-
|
533 |
};
|
534 |
|
535 |
return (
|
536 |
-
|
537 |
-
|
538 |
-
|
539 |
-
|
540 |
-
|
541 |
-
|
542 |
-
|
543 |
-
|
544 |
-
|
545 |
-
|
546 |
-
|
547 |
-
|
548 |
-
|
549 |
-
|
550 |
-
|
551 |
-
|
552 |
-
|
553 |
-
|
554 |
-
|
555 |
-
|
556 |
-
|
557 |
-
|
558 |
-
|
559 |
-
</
|
560 |
-
<
|
561 |
-
|
562 |
-
|
563 |
-
{navItems.map((item) => (
|
564 |
-
<Link
|
565 |
-
key={item.name}
|
566 |
-
to={item.path}
|
567 |
-
className={cn(
|
568 |
-
"flex items-center gap-2 p-2 rounded-md text-sm",
|
569 |
-
item.path === location.pathname
|
570 |
-
? "bg-financial-accent/30 border border-financial-accent/30"
|
571 |
-
: "hover:bg-muted text-muted-foreground"
|
572 |
-
)}
|
573 |
-
onClick={() => setIsSidebarOpen(false)}
|
574 |
-
>
|
575 |
-
<item.icon className="h-4 w-4" />
|
576 |
-
<span>{item.name}</span>
|
577 |
-
</Link>
|
578 |
-
))}
|
579 |
-
</div>
|
580 |
-
<div className="flex items-center justify-between">
|
581 |
-
<div className="flex items-center gap-2">
|
582 |
-
<Bot className="h-5 w-5 text-financial-accent" />
|
583 |
-
<span className="font-semibold">Recent Chats</span>
|
584 |
-
</div>
|
585 |
-
<Button
|
586 |
-
onClick={handleNewChat}
|
587 |
-
variant="ghost"
|
588 |
-
className="h-8 w-8 p-0 hover:bg-financial-accent/10 hover:text-financial-accent"
|
589 |
-
>
|
590 |
-
<Plus className="h-4 w-4" />
|
591 |
-
</Button>
|
592 |
</div>
|
|
|
|
|
|
|
593 |
</div>
|
594 |
|
595 |
-
|
596 |
-
|
597 |
-
|
598 |
-
|
599 |
-
</div>
|
600 |
) : (
|
601 |
-
|
602 |
-
<div
|
603 |
-
|
604 |
-
|
605 |
-
|
606 |
-
|
607 |
-
|
608 |
-
|
609 |
-
|
610 |
-
|
611 |
-
|
612 |
-
|
613 |
-
|
614 |
-
|
615 |
-
|
616 |
-
)}>
|
617 |
-
<Bot className="h-3.5 w-3.5 mr-1.5 opacity-70" />
|
618 |
-
{chat.title}
|
619 |
-
{chat.id === activeChat?.id && isGeneratingTitle && (
|
620 |
-
<span className="ml-1.5 inline-block h-2 w-2 rounded-full bg-financial-accent/70 animate-pulse"></span>
|
621 |
-
)}
|
622 |
-
</div>
|
623 |
-
<div className="text-xs text-muted-foreground">
|
624 |
-
{chat.messages.filter(m => m.sender === "user").length} messages • {format(new Date(chat.updatedAt), "MMM d")}
|
625 |
-
</div>
|
626 |
</div>
|
627 |
-
<Button
|
628 |
-
variant="ghost"
|
629 |
-
size="icon"
|
630 |
-
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10 hover:text-destructive"
|
631 |
-
onClick={(e) => handleDeleteChat(chat.id, e)}
|
632 |
-
>
|
633 |
-
<TrashIcon className="h-3.5 w-3.5" />
|
634 |
-
</Button>
|
635 |
</div>
|
636 |
-
))
|
637 |
-
)}
|
638 |
-
</div>
|
639 |
-
|
640 |
-
{/* Sidebar Footer */}
|
641 |
-
<div className="p-3 border-t mt-auto flex justify-between items-center">
|
642 |
-
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1.5">
|
643 |
-
<Settings className="h-3.5 w-3.5" />
|
644 |
-
<span>Settings</span>
|
645 |
-
</Link>
|
646 |
-
|
647 |
-
<ModeToggle />
|
648 |
-
</div>
|
649 |
-
</div>
|
650 |
-
</div>
|
651 |
|
652 |
-
|
653 |
-
|
654 |
-
|
655 |
-
|
656 |
-
|
657 |
-
|
658 |
-
|
659 |
-
|
660 |
-
|
661 |
-
|
662 |
</div>
|
663 |
-
<Button variant="ghost" size="icon" onClick={handleNewChat}>
|
664 |
-
<Plus className="h-5 w-5" />
|
665 |
-
</Button>
|
666 |
-
</div>
|
667 |
-
|
668 |
-
{/* Chat Area */}
|
669 |
-
<div className="relative flex-1 overflow-hidden">
|
670 |
-
{!activeChat ? (
|
671 |
-
<div className="h-full flex flex-col items-center justify-center">
|
672 |
-
<div className="text-center max-w-md p-8 bg-card/40 backdrop-blur-sm rounded-2xl border border-border/50">
|
673 |
-
<Bot className="h-10 w-10 mx-auto mb-4 text-financial-accent" />
|
674 |
-
<h2 className="text-2xl font-bold mb-2 bg-gradient-to-br from-financial-accent to-financial-light-accent bg-clip-text text-transparent">Welcome to Insight AI</h2>
|
675 |
-
<p className="text-muted-foreground mb-6">
|
676 |
-
Ask me anything, and I'll do my best to help you
|
677 |
-
</p>
|
678 |
-
<Button onClick={handleNewChat} className="animate-bounce-in bg-financial-accent hover:bg-financial-accent/90">
|
679 |
-
Start a new conversation
|
680 |
-
</Button>
|
681 |
-
</div>
|
682 |
-
</div>
|
683 |
-
) : (
|
684 |
-
<>
|
685 |
-
<div className="h-full overflow-y-auto px-4 pb-32 pt-4">
|
686 |
-
<div className="max-w-3xl mx-auto space-y-4">
|
687 |
-
{activeChat.messages.map((message) => (
|
688 |
-
<ChatBubble
|
689 |
-
key={message.id}
|
690 |
-
message={message}
|
691 |
-
onViewSearchResults={handleViewSearchResults}
|
692 |
-
onRetry={handleRetryMessage}
|
693 |
-
/>
|
694 |
-
))}
|
695 |
-
<div ref={messagesEndRef} />
|
696 |
-
</div>
|
697 |
-
</div>
|
698 |
-
|
699 |
-
{/* Input Area */}
|
700 |
-
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background/80 dark:bg-background/50 backdrop-blur-md border-t border-border/30">
|
701 |
-
<div className="max-w-3xl mx-auto">
|
702 |
-
<form onSubmit={handleSendMessage} className="relative">
|
703 |
-
<div className="relative">
|
704 |
-
<Input
|
705 |
-
ref={inputRef}
|
706 |
-
type="text"
|
707 |
-
placeholder="Ask me anything..."
|
708 |
-
value={inputValue}
|
709 |
-
onChange={(e) => setInputValue(e.target.value)}
|
710 |
-
className="pr-20 py-6 text-base bg-background/50 border border-border/50 focus:border-financial-accent/50 focus-visible:ring-1 focus-visible:ring-financial-accent/50 rounded-xl"
|
711 |
-
disabled={isLoading}
|
712 |
-
/>
|
713 |
-
|
714 |
-
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
715 |
-
<Button
|
716 |
-
type="submit"
|
717 |
-
size="icon"
|
718 |
-
className="rounded-lg bg-financial-accent hover:bg-financial-accent/90"
|
719 |
-
disabled={isLoading || !inputValue.trim()}
|
720 |
-
>
|
721 |
-
{isLoading ? (
|
722 |
-
<div className="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
723 |
-
) : (
|
724 |
-
<CornerDownLeft className="h-4 w-4" />
|
725 |
-
)}
|
726 |
-
</Button>
|
727 |
-
</div>
|
728 |
-
</div>
|
729 |
-
|
730 |
-
<div className="mt-2 text-center text-xs text-muted-foreground">
|
731 |
-
Insight AI may produce inaccurate information. Verify important details.
|
732 |
-
</div>
|
733 |
-
</form>
|
734 |
-
</div>
|
735 |
-
</div>
|
736 |
-
</>
|
737 |
-
)}
|
738 |
</div>
|
739 |
</div>
|
740 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
741 |
);
|
742 |
};
|
743 |
-
|
744 |
-
export default HomePage;
|
|
|
|
|
1 |
import { useState, useRef, useEffect } from "react";
|
2 |
+
import { Send, Plus, PanelLeft, Bot } from "lucide-react";
|
|
|
3 |
import { Button } from "@/components/ui/button";
|
4 |
+
import { apiService, Message as APIMessage } from "@/services/apiService";
|
|
|
5 |
import { toast } from "@/components/ui/sonner";
|
6 |
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";
|
14 |
+
import { WelcomeScreen } from "./WelcomeScreen";
|
15 |
+
import { DeleteChatDialog } from "./DeleteChatDialog";
|
16 |
+
|
17 |
+
interface ChatInterfaceProps {
|
18 |
+
onOpenSettings: () => void;
|
19 |
+
onOpenSources: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
}
|
21 |
|
22 |
+
const WELCOME_MESSAGE = "Hello! I'm Insight AI, How can I help you today?";
|
23 |
|
24 |
const generateId = () => Math.random().toString(36).substring(2, 11);
|
25 |
|
26 |
+
export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfaceProps) => {
|
|
|
|
|
|
|
|
|
|
|
27 |
const [chats, setChats] = useState<Chat[]>(() => {
|
28 |
const savedChats = storage.get<Chat[]>(STORAGE_KEYS.CHATS);
|
29 |
if (savedChats) {
|
|
|
50 |
if (chats.length > 0) {
|
51 |
return chats[0];
|
52 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
});
|
54 |
|
55 |
const [inputValue, setInputValue] = useState("");
|
56 |
const [isLoading, setIsLoading] = useState(false);
|
57 |
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
58 |
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
59 |
+
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
|
60 |
+
const [chatToDelete, setChatToDelete] = useState<string | null>(null);
|
61 |
|
|
|
62 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
63 |
const inputRef = useRef<HTMLInputElement>(null);
|
64 |
|
|
|
76 |
: chats;
|
77 |
|
78 |
storage.set(STORAGE_KEYS.CHATS, allChats);
|
|
|
|
|
|
|
79 |
}, [chats, activeChat]);
|
80 |
|
81 |
const scrollToBottom = () => {
|
|
|
95 |
}
|
96 |
}, [isLoading]);
|
97 |
|
98 |
+
// Handle new chat and chat selection events
|
99 |
useEffect(() => {
|
100 |
+
const handleNewChat = () => createNewChat();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
101 |
const handleSelectChat = (e: Event) => {
|
102 |
const customEvent = e as CustomEvent;
|
103 |
const chatId = customEvent.detail?.chatId;
|
104 |
if (chatId) {
|
105 |
+
selectChat(chatId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
106 |
}
|
107 |
};
|
|
|
108 |
const handleDeleteChat = (e: Event) => {
|
109 |
const customEvent = e as CustomEvent;
|
110 |
const chatId = customEvent.detail?.chatId;
|
111 |
if (chatId) {
|
112 |
+
setChatToDelete(chatId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
113 |
}
|
114 |
};
|
115 |
|
|
|
125 |
}, [chats, activeChat]);
|
126 |
|
127 |
const generateChatTitle = async (query: string) => {
|
128 |
+
if (!activeChat || activeChat.title !== "New Chat") return;
|
|
|
|
|
|
|
129 |
|
130 |
setIsGeneratingTitle(true);
|
131 |
|
132 |
try {
|
133 |
+
const response = await apiService.generateTitle(query);
|
134 |
|
135 |
if (response.title) {
|
136 |
+
// Update the active chat with the new title
|
137 |
setActiveChat(prevChat => {
|
138 |
if (!prevChat) return null;
|
139 |
|
140 |
+
const updatedChat = { ...prevChat, title: response.title };
|
|
|
|
|
|
|
141 |
|
142 |
// Update chats list
|
143 |
setChats(prevChats =>
|
|
|
151 |
}
|
152 |
} catch (error) {
|
153 |
console.error("Error generating chat title:", error);
|
154 |
+
// Fallback to using query as title
|
155 |
if (activeChat.title === "New Chat") {
|
156 |
setActiveChat(prevChat => {
|
157 |
if (!prevChat) return null;
|
|
|
161 |
title: query.slice(0, 30) + (query.length > 30 ? '...' : '')
|
162 |
};
|
163 |
|
|
|
164 |
setChats(prevChats =>
|
165 |
prevChats.map(chat =>
|
166 |
chat.id === updatedChat.id ? updatedChat : chat
|
|
|
209 |
try {
|
210 |
// Prepare chat history for the API
|
211 |
const chatHistory: APIMessage[] = updatedChat.messages
|
212 |
+
.filter(msg => !msg.isLoading && msg.content) // filter out loading messages
|
213 |
.slice(0, -1) // exclude the loading message we just added
|
214 |
.map(msg => ({
|
215 |
role: msg.sender === "user" ? "user" : "assistant",
|
216 |
content: msg.content
|
217 |
}));
|
218 |
|
219 |
+
const response = await apiService.queryRulings({
|
220 |
query: userMessage.content,
|
221 |
chat_history: chatHistory
|
222 |
});
|
|
|
253 |
}
|
254 |
|
255 |
} catch (error) {
|
256 |
+
console.error("Error querying AI:", error);
|
257 |
|
258 |
// Replace loading message with error
|
259 |
const updatedMessages = updatedChat.messages.map(msg =>
|
|
|
282 |
}
|
283 |
};
|
284 |
|
285 |
+
const createNewChat = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
286 |
const newChat: Chat = {
|
287 |
id: generateId(),
|
288 |
title: "New Chat",
|
289 |
messages: [
|
290 |
{
|
291 |
+
id: "welcomems",
|
292 |
content: WELCOME_MESSAGE,
|
293 |
sender: "system" as const,
|
294 |
timestamp: new Date()
|
|
|
300 |
|
301 |
setActiveChat(newChat);
|
302 |
setChats(prev => [newChat, ...prev]);
|
303 |
+
setTimeout(() => {
|
304 |
+
inputRef.current?.focus();
|
305 |
+
}, 100);
|
306 |
setIsSidebarOpen(false);
|
307 |
};
|
308 |
|
309 |
+
const selectChat = (chatId: string) => {
|
310 |
const selectedChat = chats.find(chat => chat.id === chatId);
|
311 |
if (selectedChat) {
|
312 |
setActiveChat(selectedChat);
|
|
|
317 |
}
|
318 |
};
|
319 |
|
320 |
+
const deleteChat = (chatId: string) => {
|
|
|
|
|
321 |
const updatedChats = chats.filter(chat => chat.id !== chatId);
|
322 |
setChats(updatedChats);
|
323 |
|
|
|
327 |
|
328 |
// If no chats left, create a new one
|
329 |
if (updatedChats.length === 0) {
|
330 |
+
createNewChat();
|
331 |
}
|
332 |
}
|
333 |
+
|
334 |
+
// Clear the chat being deleted
|
335 |
+
setChatToDelete(null);
|
336 |
+
};
|
337 |
+
|
338 |
+
const handleDeleteMessage = (messageId: string) => {
|
339 |
+
if (!activeChat) return;
|
340 |
+
|
341 |
+
// Find the index of the message to delete
|
342 |
+
const messageIndex = activeChat.messages.findIndex(msg => msg.id === messageId);
|
343 |
+
if (messageIndex === -1) return;
|
344 |
+
|
345 |
+
// Determine if we need to delete a pair (user message + assistant response)
|
346 |
+
const isUserMessage = activeChat.messages[messageIndex].sender === "user";
|
347 |
+
const updatedMessages = [...activeChat.messages];
|
348 |
+
|
349 |
+
if (isUserMessage && messageIndex + 1 < updatedMessages.length &&
|
350 |
+
updatedMessages[messageIndex + 1].sender === "system") {
|
351 |
+
// Remove both the user message and the following assistant response
|
352 |
+
updatedMessages.splice(messageIndex, 2);
|
353 |
+
} else if (!isUserMessage && messageIndex > 0 &&
|
354 |
+
updatedMessages[messageIndex - 1].sender === "user") {
|
355 |
+
// Remove both the assistant message and the preceding user message
|
356 |
+
updatedMessages.splice(messageIndex - 1, 2);
|
357 |
+
} else {
|
358 |
+
// Just remove the single message
|
359 |
+
updatedMessages.splice(messageIndex, 1);
|
360 |
+
}
|
361 |
+
|
362 |
+
const updatedChat = {
|
363 |
+
...activeChat,
|
364 |
+
messages: updatedMessages,
|
365 |
+
updatedAt: new Date()
|
366 |
+
};
|
367 |
+
|
368 |
+
setActiveChat(updatedChat);
|
369 |
+
|
370 |
+
// Update chats list
|
371 |
+
setChats(prevChats =>
|
372 |
+
prevChats.map(chat =>
|
373 |
+
chat.id === updatedChat.id ? updatedChat : chat
|
374 |
+
)
|
375 |
+
);
|
376 |
+
};
|
377 |
+
|
378 |
+
const handleRegenerateMessage = (messageId: string) => {
|
379 |
+
if (!activeChat) return;
|
380 |
+
|
381 |
+
// Find the system message that needs to be regenerated
|
382 |
+
const messageIndex = activeChat.messages.findIndex(
|
383 |
+
msg => msg.id === messageId && msg.sender === "system"
|
384 |
+
);
|
385 |
+
|
386 |
+
if (messageIndex < 0) return;
|
387 |
+
|
388 |
+
const message = activeChat.messages[messageIndex];
|
389 |
+
|
390 |
+
// Find the last user message before this system message
|
391 |
+
let userMessageContent = "";
|
392 |
+
let userMessageIndex = -1;
|
393 |
+
|
394 |
+
for (let i = messageIndex - 1; i >= 0; i--) {
|
395 |
+
if (activeChat.messages[i].sender === "user") {
|
396 |
+
userMessageContent = activeChat.messages[i].content;
|
397 |
+
userMessageIndex = i;
|
398 |
+
break;
|
399 |
+
}
|
400 |
+
}
|
401 |
+
|
402 |
+
if (!userMessageContent) return;
|
403 |
+
|
404 |
+
// Create a new variation
|
405 |
+
const variationId = generateId();
|
406 |
+
const now = new Date();
|
407 |
+
|
408 |
+
// Prepare the updated message with a loading variation
|
409 |
+
const variations = message.variations || [];
|
410 |
+
const existingVariations = variations.map(v => ({ ...v }));
|
411 |
+
|
412 |
+
// Create a new variations array with the loading state
|
413 |
+
const updatedVariations = [
|
414 |
+
...existingVariations,
|
415 |
+
{ id: variationId, content: "", timestamp: now }
|
416 |
+
];
|
417 |
+
|
418 |
+
// Update the message with loading state
|
419 |
+
const updatedMessages = [...activeChat.messages];
|
420 |
+
updatedMessages[messageIndex] = {
|
421 |
+
...updatedMessages[messageIndex],
|
422 |
+
isLoading: true,
|
423 |
+
variations: updatedVariations,
|
424 |
+
activeVariation: variationId
|
425 |
+
};
|
426 |
+
|
427 |
+
const updatedChat = {
|
428 |
+
...activeChat,
|
429 |
+
messages: updatedMessages
|
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 |
+
role: msg.sender === "user" ? "user" : "assistant",
|
442 |
+
content: msg.content
|
443 |
+
}));
|
444 |
+
|
445 |
+
// Send the API request
|
446 |
+
apiService.queryRulings({
|
447 |
+
query: userMessageContent,
|
448 |
+
chat_history: chatHistory
|
449 |
+
})
|
450 |
+
.then(response => {
|
451 |
+
// Update the variation with the actual response
|
452 |
+
const finalVariations = updatedMessages[messageIndex].variations!.map(v =>
|
453 |
+
v.id === variationId
|
454 |
+
? { ...v, content: response.answer, timestamp: new Date() }
|
455 |
+
: v
|
456 |
+
);
|
457 |
+
|
458 |
+
const finalMessages = [...updatedMessages];
|
459 |
+
finalMessages[messageIndex] = {
|
460 |
+
...finalMessages[messageIndex],
|
461 |
+
variations: finalVariations,
|
462 |
+
isLoading: false,
|
463 |
+
error: false,
|
464 |
+
result: response.retrieved_sources,
|
465 |
+
activeVariation: variationId
|
466 |
+
};
|
467 |
+
|
468 |
+
const finalChat = {
|
469 |
+
...updatedChat,
|
470 |
+
messages: finalMessages
|
471 |
+
};
|
472 |
+
|
473 |
+
setActiveChat(finalChat);
|
474 |
+
setChats(prevChats =>
|
475 |
+
prevChats.map(chat =>
|
476 |
+
chat.id === finalChat.id ? finalChat : chat
|
477 |
+
)
|
478 |
+
);
|
479 |
+
})
|
480 |
+
.catch(error => {
|
481 |
+
console.error("Error regenerating response:", error);
|
482 |
+
|
483 |
+
// Remove the failed variation
|
484 |
+
const finalVariations = updatedMessages[messageIndex].variations!.filter(v =>
|
485 |
+
v.id !== variationId
|
486 |
+
);
|
487 |
+
|
488 |
+
const finalMessages = [...updatedMessages];
|
489 |
+
finalMessages[messageIndex] = {
|
490 |
+
...finalMessages[messageIndex],
|
491 |
+
variations: finalVariations,
|
492 |
+
isLoading: false,
|
493 |
+
activeVariation: finalVariations.length > 0 ? finalVariations[0].id : undefined
|
494 |
+
};
|
495 |
+
|
496 |
+
setActiveChat({
|
497 |
+
...updatedChat,
|
498 |
+
messages: finalMessages
|
499 |
+
});
|
500 |
+
|
501 |
+
toast.error("Failed to generate variation");
|
502 |
+
})
|
503 |
+
.finally(() => {
|
504 |
+
setIsLoading(false);
|
505 |
+
setTimeout(() => {
|
506 |
+
inputRef.current?.focus();
|
507 |
+
}, 100);
|
508 |
+
});
|
509 |
+
};
|
510 |
+
|
511 |
+
const handleSelectVariation = (messageId: string, variationId: string) => {
|
512 |
+
if (!activeChat) return;
|
513 |
+
|
514 |
+
// Find the message
|
515 |
+
const messageIndex = activeChat.messages.findIndex(msg => msg.id === messageId);
|
516 |
+
if (messageIndex < 0) return;
|
517 |
+
|
518 |
+
// Set the active variation
|
519 |
+
const updatedMessages = [...activeChat.messages];
|
520 |
+
updatedMessages[messageIndex] = {
|
521 |
+
...updatedMessages[messageIndex],
|
522 |
+
activeVariation: variationId
|
523 |
+
};
|
524 |
+
|
525 |
+
const updatedChat = {
|
526 |
+
...activeChat,
|
527 |
+
messages: updatedMessages
|
528 |
+
};
|
529 |
+
|
530 |
+
setActiveChat(updatedChat);
|
531 |
+
|
532 |
+
// Update chats list
|
533 |
+
setChats(prevChats =>
|
534 |
+
prevChats.map(chat =>
|
535 |
+
chat.id === updatedChat.id ? updatedChat : chat
|
536 |
+
)
|
537 |
+
);
|
538 |
};
|
539 |
|
540 |
const handleRetryMessage = (messageId: string) => {
|
|
|
584 |
}));
|
585 |
|
586 |
// Retry the query
|
587 |
+
apiService.queryRulings({
|
588 |
query: userMessageContent,
|
589 |
chat_history: chatHistory
|
590 |
})
|
|
|
604 |
};
|
605 |
|
606 |
setActiveChat(finalChat);
|
|
|
|
|
607 |
setChats(prevChats =>
|
608 |
prevChats.map(chat =>
|
609 |
chat.id === finalChat.id ? finalChat : chat
|
|
|
636 |
});
|
637 |
};
|
638 |
|
639 |
+
const openProfileModal = () => {
|
640 |
+
setIsProfileModalOpen(true);
|
641 |
};
|
642 |
|
643 |
return (
|
644 |
+
<>
|
645 |
+
<div className="flex h-full w-full">
|
646 |
+
{/* Chat Sidebar */}
|
647 |
+
<ChatSidebar
|
648 |
+
chats={chats}
|
649 |
+
activeChat={activeChat}
|
650 |
+
isGeneratingTitle={isGeneratingTitle}
|
651 |
+
createNewChat={createNewChat}
|
652 |
+
selectChat={selectChat}
|
653 |
+
onRequestDelete={setChatToDelete}
|
654 |
+
onOpenSettings={onOpenSettings}
|
655 |
+
onOpenSources={onOpenSources}
|
656 |
+
openProfileModal={openProfileModal}
|
657 |
+
isSidebarOpen={isSidebarOpen}
|
658 |
+
setIsSidebarOpen={setIsSidebarOpen}
|
659 |
+
/>
|
660 |
+
|
661 |
+
{/* Chat Main Area */}
|
662 |
+
<div className="flex-1 flex flex-col overflow-hidden">
|
663 |
+
{/* Mobile Header */}
|
664 |
+
<div className="md:hidden p-2 flex items-center border-b">
|
665 |
+
<Button variant="ghost" size="icon" onClick={() => setIsSidebarOpen(true)}>
|
666 |
+
<PanelLeft className="h-5 w-5" />
|
667 |
+
</Button>
|
668 |
+
<div className="mx-auto font-medium flex items-center">
|
669 |
+
<Bot className="h-4 w-4 mr-1.5" />
|
670 |
+
{activeChat?.title || "New Chat"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
671 |
</div>
|
672 |
+
<Button variant="ghost" size="icon" onClick={createNewChat}>
|
673 |
+
<Plus className="h-5 w-5" />
|
674 |
+
</Button>
|
675 |
</div>
|
676 |
|
677 |
+
{/* Chat Area */}
|
678 |
+
<div className="relative flex-1 overflow-hidden">
|
679 |
+
{!activeChat ? (
|
680 |
+
<WelcomeScreen onCreateNewChat={createNewChat} />
|
|
|
681 |
) : (
|
682 |
+
<>
|
683 |
+
<div className="h-full overflow-y-auto px-4 pb-32 pt-4">
|
684 |
+
<div className="max-w-3xl mx-auto space-y-4">
|
685 |
+
{activeChat.messages.map((message) => (
|
686 |
+
<ChatBubble
|
687 |
+
key={message.id}
|
688 |
+
message={message}
|
689 |
+
onViewSearchResults={onOpenSources}
|
690 |
+
onRetry={handleRetryMessage}
|
691 |
+
onRegenerate={handleRegenerateMessage}
|
692 |
+
onDelete={handleDeleteMessage}
|
693 |
+
onSelectVariation={handleSelectVariation}
|
694 |
+
/>
|
695 |
+
))}
|
696 |
+
<div ref={messagesEndRef} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
697 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
698 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
699 |
|
700 |
+
{/* Input Area */}
|
701 |
+
<ChatInputArea
|
702 |
+
inputRef={inputRef}
|
703 |
+
inputValue={inputValue}
|
704 |
+
setInputValue={setInputValue}
|
705 |
+
handleSendMessage={handleSendMessage}
|
706 |
+
isLoading={isLoading}
|
707 |
+
/>
|
708 |
+
</>
|
709 |
+
)}
|
710 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
711 |
</div>
|
712 |
</div>
|
713 |
+
|
714 |
+
{/* Profile Modal */}
|
715 |
+
<ProfileModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} />
|
716 |
+
|
717 |
+
{/* Delete Chat Confirmation Dialog */}
|
718 |
+
<DeleteChatDialog
|
719 |
+
isOpen={chatToDelete !== null}
|
720 |
+
onOpenChange={() => setChatToDelete(null)}
|
721 |
+
onDelete={() => chatToDelete && deleteChat(chatToDelete)}
|
722 |
+
/>
|
723 |
+
</>
|
724 |
);
|
725 |
};
|
|
|
|
frontend/src/components/chat/ChatMessage.tsx
CHANGED
@@ -1,3 +1,4 @@
|
|
|
|
1 |
// src/components/ChatMessage.tsx
|
2 |
import React, { useState, useMemo } from 'react'
|
3 |
import ReactMarkdown from 'react-markdown'
|
@@ -16,9 +17,18 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|
16 |
className,
|
17 |
}) => {
|
18 |
|
19 |
-
//
|
20 |
-
const
|
21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
/<source\s+path=["'](.+?)["']\s*\/>/g,
|
23 |
(_match, path) => {
|
24 |
const filename = path
|
@@ -33,8 +43,8 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|
33 |
</svg>
|
34 |
</a>`
|
35 |
}
|
36 |
-
)
|
37 |
-
}, [content])
|
38 |
|
39 |
return (
|
40 |
<div
|
@@ -65,7 +75,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|
65 |
),
|
66 |
}}
|
67 |
>
|
68 |
-
{
|
69 |
</ReactMarkdown>
|
70 |
</div>
|
71 |
)
|
|
|
1 |
+
|
2 |
// src/components/ChatMessage.tsx
|
3 |
import React, { useState, useMemo } from 'react'
|
4 |
import ReactMarkdown from 'react-markdown'
|
|
|
17 |
className,
|
18 |
}) => {
|
19 |
|
20 |
+
// Process thinking tags
|
21 |
+
const processedContent = useMemo(() => {
|
22 |
+
// Replace <think>...</think> tags with a special format
|
23 |
+
const contentWithProcessedThinking = content.replace(
|
24 |
+
/<think>([\s\S]*?)<\/think>/g,
|
25 |
+
(_, thinkContent) => {
|
26 |
+
return `<div class="think-block">${thinkContent}</div>`;
|
27 |
+
}
|
28 |
+
);
|
29 |
+
|
30 |
+
// Continue processing source tags as before
|
31 |
+
return contentWithProcessedThinking.replace(
|
32 |
/<source\s+path=["'](.+?)["']\s*\/>/g,
|
33 |
(_match, path) => {
|
34 |
const filename = path
|
|
|
43 |
</svg>
|
44 |
</a>`
|
45 |
}
|
46 |
+
);
|
47 |
+
}, [content]);
|
48 |
|
49 |
return (
|
50 |
<div
|
|
|
75 |
),
|
76 |
}}
|
77 |
>
|
78 |
+
{processedContent}
|
79 |
</ReactMarkdown>
|
80 |
</div>
|
81 |
)
|
frontend/src/components/chat/ChatSidebar.tsx
ADDED
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React from "react";
|
3 |
+
import { format } from "date-fns";
|
4 |
+
import { Bot, TrashIcon, User, FileText, Settings, PanelLeft, Plus } from "lucide-react";
|
5 |
+
import { Button } from "@/components/ui/button";
|
6 |
+
import { cn } from "@/lib/utils";
|
7 |
+
import { Chat } from "@/types/chat";
|
8 |
+
import { ModeToggle } from "@/components/layout/ModeToggle";
|
9 |
+
|
10 |
+
interface ChatSidebarProps {
|
11 |
+
chats: Chat[];
|
12 |
+
activeChat: Chat | null;
|
13 |
+
isGeneratingTitle: boolean;
|
14 |
+
createNewChat: () => void;
|
15 |
+
selectChat: (id: string) => void;
|
16 |
+
onRequestDelete: (id: string) => void;
|
17 |
+
onOpenSettings: () => void;
|
18 |
+
onOpenSources: () => void;
|
19 |
+
openProfileModal: () => void;
|
20 |
+
isSidebarOpen: boolean;
|
21 |
+
setIsSidebarOpen: (open: boolean) => void;
|
22 |
+
}
|
23 |
+
|
24 |
+
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
25 |
+
chats,
|
26 |
+
activeChat,
|
27 |
+
isGeneratingTitle,
|
28 |
+
createNewChat,
|
29 |
+
selectChat,
|
30 |
+
onRequestDelete,
|
31 |
+
onOpenSettings,
|
32 |
+
onOpenSources,
|
33 |
+
openProfileModal,
|
34 |
+
isSidebarOpen,
|
35 |
+
setIsSidebarOpen,
|
36 |
+
}) => {
|
37 |
+
return (
|
38 |
+
<div className={cn(
|
39 |
+
"fixed top-0 bottom-0 left-0 z-20 bg-background/90 backdrop-blur-lg",
|
40 |
+
"transition-transform duration-300 ease-in-out",
|
41 |
+
isSidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
42 |
+
"w-72 lg:w-80 border-r border-border/50 flex-shrink-0",
|
43 |
+
"md:relative md:inset-auto h-full md:z-0"
|
44 |
+
)}>
|
45 |
+
<div className="flex flex-col h-full">
|
46 |
+
<div className="p-4 pb-0">
|
47 |
+
<div className="flex justify-between items-center">
|
48 |
+
<div className="flex items-center space-x-2">
|
49 |
+
<div className="h-8 w-8 bg-primary/90 rounded-md flex items-center justify-center">
|
50 |
+
<span className="text-white font-bold text-lg">AI</span>
|
51 |
+
</div>
|
52 |
+
<h1 className="text-xl font-semibold">
|
53 |
+
Insight AI
|
54 |
+
</h1>
|
55 |
+
</div>
|
56 |
+
<Button className="md:hidden" variant="ghost" size="icon" onClick={() => setIsSidebarOpen(false)}>
|
57 |
+
<PanelLeft className="h-5 w-5" />
|
58 |
+
</Button>
|
59 |
+
</div>
|
60 |
+
|
61 |
+
<div className="py-4">
|
62 |
+
<Button
|
63 |
+
onClick={createNewChat}
|
64 |
+
className="w-full justify-start gap-2"
|
65 |
+
variant="outline"
|
66 |
+
>
|
67 |
+
<Plus className="h-4 w-4" />
|
68 |
+
New Chat
|
69 |
+
</Button>
|
70 |
+
</div>
|
71 |
+
|
72 |
+
<div className="flex items-center justify-between">
|
73 |
+
<div className="flex items-center gap-2">
|
74 |
+
<Bot className="h-5 w-5 text-primary" />
|
75 |
+
<span className="font-medium">Recent Chats</span>
|
76 |
+
</div>
|
77 |
+
</div>
|
78 |
+
</div>
|
79 |
+
|
80 |
+
<div className="flex-1 overflow-y-auto p-2 space-y-1 scrollbar-thin">
|
81 |
+
{chats.length === 0 ? (
|
82 |
+
<div className="text-center text-muted-foreground p-4">
|
83 |
+
No conversations yet. Start a new one!
|
84 |
+
</div>
|
85 |
+
) : (
|
86 |
+
chats.map(chat => (
|
87 |
+
<div
|
88 |
+
key={chat.id}
|
89 |
+
onClick={() => selectChat(chat.id)}
|
90 |
+
className={cn(
|
91 |
+
"flex items-center justify-between p-2 px-3 rounded-lg cursor-pointer group transition-all",
|
92 |
+
activeChat?.id === chat.id
|
93 |
+
? "bg-primary/10 border border-primary/20"
|
94 |
+
: "hover:bg-muted/50 border border-transparent"
|
95 |
+
)}
|
96 |
+
>
|
97 |
+
<div className="flex-1 truncate">
|
98 |
+
<div className={cn(
|
99 |
+
"font-medium truncate flex items-center",
|
100 |
+
activeChat?.id === chat.id && "text-primary"
|
101 |
+
)}>
|
102 |
+
<Bot className="h-3.5 w-3.5 mr-1.5 opacity-70" />
|
103 |
+
{chat.title}
|
104 |
+
{chat.id === activeChat?.id && isGeneratingTitle && (
|
105 |
+
<span className="ml-1.5 inline-block h-2 w-2 rounded-full bg-primary/70 animate-pulse"></span>
|
106 |
+
)}
|
107 |
+
</div>
|
108 |
+
<div className="text-xs text-muted-foreground">
|
109 |
+
{chat.messages.filter(m => m.sender === "user").length} messages • {format(new Date(chat.updatedAt), "MMM d")}
|
110 |
+
</div>
|
111 |
+
</div>
|
112 |
+
<Button
|
113 |
+
variant="ghost"
|
114 |
+
size="icon"
|
115 |
+
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10 hover:text-destructive"
|
116 |
+
onClick={(e) => {
|
117 |
+
e.stopPropagation();
|
118 |
+
onRequestDelete(chat.id);
|
119 |
+
}}
|
120 |
+
>
|
121 |
+
<TrashIcon className="h-3.5 w-3.5" />
|
122 |
+
</Button>
|
123 |
+
</div>
|
124 |
+
))
|
125 |
+
)}
|
126 |
+
</div>
|
127 |
+
|
128 |
+
{/* Sidebar Footer */}
|
129 |
+
<div className="p-3 space-y-2">
|
130 |
+
<Button
|
131 |
+
onClick={openProfileModal}
|
132 |
+
variant="ghost"
|
133 |
+
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
|
134 |
+
size="sm"
|
135 |
+
>
|
136 |
+
<User className="h-4 w-4" />
|
137 |
+
Profile
|
138 |
+
</Button>
|
139 |
+
|
140 |
+
<Button
|
141 |
+
onClick={onOpenSources}
|
142 |
+
variant="ghost"
|
143 |
+
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
|
144 |
+
size="sm"
|
145 |
+
>
|
146 |
+
<FileText className="h-4 w-4" />
|
147 |
+
View Sources
|
148 |
+
</Button>
|
149 |
+
|
150 |
+
<Button
|
151 |
+
onClick={onOpenSettings}
|
152 |
+
variant="ghost"
|
153 |
+
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
|
154 |
+
size="sm"
|
155 |
+
>
|
156 |
+
<Settings className="h-4 w-4" />
|
157 |
+
Settings
|
158 |
+
</Button>
|
159 |
+
|
160 |
+
<div className="flex items-center justify-between pt-2 border-t">
|
161 |
+
<span className="text-xs text-muted-foreground">Theme</span>
|
162 |
+
<ModeToggle />
|
163 |
+
</div>
|
164 |
+
</div>
|
165 |
+
</div>
|
166 |
+
</div>
|
167 |
+
);
|
168 |
+
};
|
frontend/src/components/chat/DeleteChatDialog.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 DeleteChatDialogProps {
|
15 |
+
isOpen: boolean;
|
16 |
+
onOpenChange: (open: boolean) => void;
|
17 |
+
onDelete: () => void;
|
18 |
+
}
|
19 |
+
|
20 |
+
export const DeleteChatDialog: React.FC<DeleteChatDialogProps> = ({
|
21 |
+
isOpen,
|
22 |
+
onOpenChange,
|
23 |
+
onDelete,
|
24 |
+
}) => {
|
25 |
+
return (
|
26 |
+
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
|
27 |
+
<AlertDialogContent>
|
28 |
+
<AlertDialogHeader>
|
29 |
+
<AlertDialogTitle>Delete Chat</AlertDialogTitle>
|
30 |
+
<AlertDialogDescription>
|
31 |
+
Are you sure you want to delete this chat? 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/ThinkingAnimation.tsx
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 flex-col items-start gap-2 px-4 py-3">
|
17 |
+
<div className="thinking-brain">
|
18 |
+
<svg viewBox="0 0 24 24" width="24" height="24" className="thinking-brain-svg">
|
19 |
+
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-2.5 2.5h-5A2.5 2.5 0 0 1 2 19.5v-15A2.5 2.5 0 0 1 4.5 2h5Z"
|
20 |
+
className="fill-financial-accent/20 stroke-financial-accent stroke-[1.5]" />
|
21 |
+
<path d="M12 4.5A2.5 2.5 0 0 1 14.5 2h5A2.5 2.5 0 0 1 22 4.5v15a2.5 2.5 0 0 1-2.5 2.5h-5A2.5 2.5 0 0 1 12 19.5v-15Z"
|
22 |
+
className="fill-financial-accent/10 stroke-financial-accent stroke-[1.5]" />
|
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 |
+
<span className="text-sm font-medium text-muted-foreground flex items-center">
|
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>
|
50 |
+
</span>
|
51 |
+
</div>
|
52 |
+
);
|
53 |
+
};
|
frontend/src/components/chat/WelcomeScreen.tsx
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React from "react";
|
3 |
+
import { Bot } from "lucide-react";
|
4 |
+
import { Button } from "@/components/ui/button";
|
5 |
+
|
6 |
+
interface WelcomeScreenProps {
|
7 |
+
onCreateNewChat: () => void;
|
8 |
+
}
|
9 |
+
|
10 |
+
export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ onCreateNewChat }) => {
|
11 |
+
return (
|
12 |
+
<div className="h-full flex flex-col items-center justify-center">
|
13 |
+
<div className="text-center max-w-md p-8 bg-card/40 backdrop-blur-sm rounded-2xl border border-border/50">
|
14 |
+
<Bot className="h-10 w-10 mx-auto mb-4 text-primary" />
|
15 |
+
<h2 className="text-2xl font-bold mb-2">Welcome to Insight AI</h2>
|
16 |
+
<p className="text-muted-foreground mb-6">
|
17 |
+
Ask me anything, and I'll do my best to help you
|
18 |
+
</p>
|
19 |
+
<Button onClick={onCreateNewChat} className="animate-fade-in">
|
20 |
+
Start a new conversation
|
21 |
+
</Button>
|
22 |
+
</div>
|
23 |
+
</div>
|
24 |
+
);
|
25 |
+
};
|
frontend/src/components/layout/ChatLayout.tsx
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { useState } from "react";
|
3 |
+
import { ChatInterface } from "@/components/chat/ChatInterface";
|
4 |
+
import { SettingsModal } from "@/components/modals/SettingsModal";
|
5 |
+
import { SourcesModal } from "@/components/modals/SourcesModal";
|
6 |
+
|
7 |
+
const ChatLayout = () => {
|
8 |
+
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
9 |
+
const [isSourcesOpen, setIsSourcesOpen] = useState(false);
|
10 |
+
|
11 |
+
return (
|
12 |
+
<div className="flex h-[100dvh] md:h-screen w-full">
|
13 |
+
<ChatInterface
|
14 |
+
onOpenSettings={() => setIsSettingsOpen(true)}
|
15 |
+
onOpenSources={() => setIsSourcesOpen(true)}
|
16 |
+
/>
|
17 |
+
|
18 |
+
<SettingsModal
|
19 |
+
open={isSettingsOpen}
|
20 |
+
onOpenChange={setIsSettingsOpen}
|
21 |
+
/>
|
22 |
+
|
23 |
+
<SourcesModal
|
24 |
+
open={isSourcesOpen}
|
25 |
+
onOpenChange={setIsSourcesOpen}
|
26 |
+
/>
|
27 |
+
</div>
|
28 |
+
);
|
29 |
+
};
|
30 |
+
|
31 |
+
export default ChatLayout;
|
frontend/src/components/layout/MainLayout.tsx
DELETED
@@ -1,24 +0,0 @@
|
|
1 |
-
|
2 |
-
import { ReactNode } from "react";
|
3 |
-
import { useIsMobile } from "@/hooks/use-mobile";
|
4 |
-
import { useLocation } from "react-router-dom";
|
5 |
-
|
6 |
-
interface MainLayoutProps {
|
7 |
-
children: ReactNode;
|
8 |
-
}
|
9 |
-
|
10 |
-
const MainLayout = ({ children }: MainLayoutProps) => {
|
11 |
-
const isMobile = useIsMobile();
|
12 |
-
const location = useLocation();
|
13 |
-
const isHomePage = location.pathname === "/";
|
14 |
-
|
15 |
-
return (
|
16 |
-
<div className="flex h-screen w-full">
|
17 |
-
<main className="flex-1 overflow-y-auto">
|
18 |
-
{children}
|
19 |
-
</main>
|
20 |
-
</div>
|
21 |
-
);
|
22 |
-
};
|
23 |
-
|
24 |
-
export default MainLayout;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/layout/ModeToggle.tsx
CHANGED
@@ -1,33 +1,30 @@
|
|
1 |
-
|
2 |
import { Moon, Sun } from "lucide-react";
|
3 |
import { useEffect, useState } from "react";
|
4 |
import { Button } from "@/components/ui/button";
|
|
|
5 |
|
6 |
export function ModeToggle() {
|
7 |
const [theme, setTheme] = useState(() => {
|
8 |
-
//
|
9 |
-
const storedTheme =
|
10 |
if (storedTheme) return storedTheme;
|
11 |
-
|
12 |
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
13 |
});
|
14 |
-
|
15 |
useEffect(() => {
|
16 |
const root = window.document.documentElement;
|
17 |
-
|
18 |
if (theme === "dark") {
|
19 |
root.classList.add("dark");
|
20 |
} else {
|
21 |
root.classList.remove("dark");
|
22 |
}
|
23 |
-
|
24 |
-
localStorage.setItem("theme", theme);
|
25 |
}, [theme]);
|
26 |
-
|
27 |
const toggleTheme = () => {
|
28 |
setTheme(theme === "light" ? "dark" : "light");
|
29 |
};
|
30 |
-
|
31 |
return (
|
32 |
<Button variant="outline" size="icon" onClick={toggleTheme} className="h-9 w-9">
|
33 |
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
|
|
|
1 |
import { Moon, Sun } from "lucide-react";
|
2 |
import { useEffect, useState } from "react";
|
3 |
import { Button } from "@/components/ui/button";
|
4 |
+
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
5 |
|
6 |
export function ModeToggle() {
|
7 |
const [theme, setTheme] = useState(() => {
|
8 |
+
// Use storage lib or default to system preference
|
9 |
+
const storedTheme = storage.get<string>(STORAGE_KEYS.THEME);
|
10 |
if (storedTheme) return storedTheme;
|
|
|
11 |
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
12 |
});
|
13 |
+
|
14 |
useEffect(() => {
|
15 |
const root = window.document.documentElement;
|
|
|
16 |
if (theme === "dark") {
|
17 |
root.classList.add("dark");
|
18 |
} else {
|
19 |
root.classList.remove("dark");
|
20 |
}
|
21 |
+
storage.set(STORAGE_KEYS.THEME, theme);
|
|
|
22 |
}, [theme]);
|
23 |
+
|
24 |
const toggleTheme = () => {
|
25 |
setTheme(theme === "light" ? "dark" : "light");
|
26 |
};
|
27 |
+
|
28 |
return (
|
29 |
<Button variant="outline" size="icon" onClick={toggleTheme} className="h-9 w-9">
|
30 |
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
frontend/src/components/modals/ProfileModal.tsx
ADDED
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useMemo } from "react";
|
2 |
+
import { Button } from "@/components/ui/button";
|
3 |
+
import {
|
4 |
+
Dialog,
|
5 |
+
DialogContent,
|
6 |
+
DialogDescription,
|
7 |
+
DialogFooter,
|
8 |
+
DialogHeader,
|
9 |
+
DialogTitle,
|
10 |
+
} from "@/components/ui/dialog";
|
11 |
+
import { Input } from "@/components/ui/input";
|
12 |
+
import { Label } from "@/components/ui/label";
|
13 |
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
14 |
+
import { Switch } from "@/components/ui/switch";
|
15 |
+
import { User, Mail, Shield, LogOut } from "lucide-react";
|
16 |
+
import { Separator } from "@/components/ui/separator";
|
17 |
+
import { toast } from "@/components/ui/sonner";
|
18 |
+
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
19 |
+
import {
|
20 |
+
AlertDialog,
|
21 |
+
AlertDialogAction,
|
22 |
+
AlertDialogCancel,
|
23 |
+
AlertDialogContent,
|
24 |
+
AlertDialogDescription,
|
25 |
+
AlertDialogFooter,
|
26 |
+
AlertDialogHeader,
|
27 |
+
AlertDialogTitle,
|
28 |
+
} from "@/components/ui/alert-dialog";
|
29 |
+
|
30 |
+
// Use the ESM build of multiavatar to generate SVG strings
|
31 |
+
import multiavatar from "@multiavatar/multiavatar/esm";
|
32 |
+
|
33 |
+
interface ProfileModalProps {
|
34 |
+
isOpen: boolean;
|
35 |
+
onClose: () => void;
|
36 |
+
}
|
37 |
+
|
38 |
+
interface ProfileSettings {
|
39 |
+
name: string;
|
40 |
+
email: string;
|
41 |
+
avatarUrl?: string; // now optional
|
42 |
+
saveHistory: boolean;
|
43 |
+
}
|
44 |
+
|
45 |
+
const PROFILE_STORAGE_KEY = STORAGE_KEYS.PROFILE_STORAGE_KEY;
|
46 |
+
|
47 |
+
export const ProfileModal = ({ isOpen, onClose }: ProfileModalProps) => {
|
48 |
+
const [signOutDialogOpen, setSignOutDialogOpen] = useState(false);
|
49 |
+
|
50 |
+
const [profileSettings, setProfileSettings] = useState<ProfileSettings>(() => {
|
51 |
+
// load, or default (no external URL here)
|
52 |
+
return (
|
53 |
+
storage.get<ProfileSettings>(PROFILE_STORAGE_KEY) || {
|
54 |
+
name: "John Doe",
|
55 |
+
email: "[email protected]",
|
56 |
+
avatarUrl: undefined,
|
57 |
+
saveHistory: true,
|
58 |
+
}
|
59 |
+
);
|
60 |
+
});
|
61 |
+
|
62 |
+
// reload from storage whenever modal re-opens
|
63 |
+
useEffect(() => {
|
64 |
+
if (isOpen) {
|
65 |
+
const saved = storage.get<ProfileSettings>(PROFILE_STORAGE_KEY);
|
66 |
+
if (saved) setProfileSettings(saved);
|
67 |
+
}
|
68 |
+
}, [isOpen]);
|
69 |
+
|
70 |
+
// memoize the SVG data URI so it only regenerates when the name changes
|
71 |
+
const defaultAvatarDataUri = useMemo(() => {
|
72 |
+
const svg = multiavatar(profileSettings.name);
|
73 |
+
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
74 |
+
}, [profileSettings.name]);
|
75 |
+
|
76 |
+
// pick either the custom URL or the inline SVG
|
77 |
+
const avatarSrc = profileSettings.avatarUrl || defaultAvatarDataUri;
|
78 |
+
|
79 |
+
const handleSaveChanges = () => {
|
80 |
+
storage.set(PROFILE_STORAGE_KEY, profileSettings);
|
81 |
+
toast.success("Profile settings saved successfully");
|
82 |
+
onClose();
|
83 |
+
};
|
84 |
+
|
85 |
+
const handleSignOut = () => {
|
86 |
+
toast.success("Signed out successfully");
|
87 |
+
setSignOutDialogOpen(false);
|
88 |
+
onClose();
|
89 |
+
// clear auth tokens, etc.
|
90 |
+
};
|
91 |
+
|
92 |
+
return (
|
93 |
+
<>
|
94 |
+
<Dialog open={isOpen} onOpenChange={onClose}>
|
95 |
+
<DialogContent className="sm:max-w-[500px] max-h-[85vh] overflow-y-auto">
|
96 |
+
<DialogHeader>
|
97 |
+
<DialogTitle className="text-xl flex items-center">
|
98 |
+
<User className="mr-2 h-5 w-5" />
|
99 |
+
Profile Settings
|
100 |
+
</DialogTitle>
|
101 |
+
<DialogDescription>
|
102 |
+
Manage your profile information and preferences.
|
103 |
+
</DialogDescription>
|
104 |
+
</DialogHeader>
|
105 |
+
<Separator />
|
106 |
+
|
107 |
+
<div className="flex flex-col space-y-6 py-4">
|
108 |
+
{/* Profile section */}
|
109 |
+
<div className="flex flex-col space-y-4">
|
110 |
+
<div className="flex items-center space-x-4">
|
111 |
+
<Avatar className="h-20 w-20">
|
112 |
+
<AvatarImage src={avatarSrc} alt="User avatar" />
|
113 |
+
<AvatarFallback className="bg-primary/10 text-lg">
|
114 |
+
<User className="h-8 w-8 text-primary" />
|
115 |
+
</AvatarFallback>
|
116 |
+
</Avatar>
|
117 |
+
<div className="space-y-1">
|
118 |
+
<h3 className="font-medium text-lg">{profileSettings.name}</h3>
|
119 |
+
<p className="text-sm text-muted-foreground">{profileSettings.email}</p>
|
120 |
+
<Button variant="outline" size="sm" className="mt-2">
|
121 |
+
Change picture
|
122 |
+
</Button>
|
123 |
+
</div>
|
124 |
+
</div>
|
125 |
+
|
126 |
+
<Separator />
|
127 |
+
|
128 |
+
<div className="grid gap-4">
|
129 |
+
{/* Name */}
|
130 |
+
<div className="grid grid-cols-4 items-center gap-4">
|
131 |
+
<Label htmlFor="name" className="text-right">
|
132 |
+
Name
|
133 |
+
</Label>
|
134 |
+
<Input
|
135 |
+
id="name"
|
136 |
+
value={profileSettings.name}
|
137 |
+
onChange={(e) =>
|
138 |
+
setProfileSettings({ ...profileSettings, name: e.target.value })
|
139 |
+
}
|
140 |
+
className="col-span-3"
|
141 |
+
/>
|
142 |
+
</div>
|
143 |
+
{/* Email */}
|
144 |
+
<div className="grid grid-cols-4 items-center gap-4">
|
145 |
+
<Label htmlFor="email" className="text-right">
|
146 |
+
Email
|
147 |
+
</Label>
|
148 |
+
<Input
|
149 |
+
id="email"
|
150 |
+
value={profileSettings.email}
|
151 |
+
onChange={(e) =>
|
152 |
+
setProfileSettings({ ...profileSettings, email: e.target.value })
|
153 |
+
}
|
154 |
+
className="col-span-3"
|
155 |
+
/>
|
156 |
+
</div>
|
157 |
+
</div>
|
158 |
+
</div>
|
159 |
+
|
160 |
+
{/* Privacy */}
|
161 |
+
<div className="space-y-4">
|
162 |
+
<h3 className="text-lg font-medium flex items-center gap-2">
|
163 |
+
<Shield className="h-5 w-5" />
|
164 |
+
Privacy and Data
|
165 |
+
</h3>
|
166 |
+
<Separator />
|
167 |
+
|
168 |
+
<div className="flex items-center justify-between">
|
169 |
+
<div className="space-y-0.5">
|
170 |
+
<Label htmlFor="chat-history">Save Chat History</Label>
|
171 |
+
<p className="text-sm text-muted-foreground">
|
172 |
+
Keep your chat history saved
|
173 |
+
</p>
|
174 |
+
</div>
|
175 |
+
<Switch
|
176 |
+
id="chat-history"
|
177 |
+
checked={profileSettings.saveHistory}
|
178 |
+
onCheckedChange={(saveHistory) =>
|
179 |
+
setProfileSettings({ ...profileSettings, saveHistory })
|
180 |
+
}
|
181 |
+
/>
|
182 |
+
</div>
|
183 |
+
</div>
|
184 |
+
</div>
|
185 |
+
|
186 |
+
<DialogFooter className="flex items-center justify-between mt-6">
|
187 |
+
<Button
|
188 |
+
variant="destructive"
|
189 |
+
size="sm"
|
190 |
+
onClick={() => setSignOutDialogOpen(true)}
|
191 |
+
>
|
192 |
+
<LogOut className="mr-2 h-4 w-4" />
|
193 |
+
Sign Out
|
194 |
+
</Button>
|
195 |
+
<Button onClick={handleSaveChanges}>Save changes</Button>
|
196 |
+
</DialogFooter>
|
197 |
+
</DialogContent>
|
198 |
+
</Dialog>
|
199 |
+
|
200 |
+
{/* Sign-out confirmation */}
|
201 |
+
<AlertDialog open={signOutDialogOpen} onOpenChange={setSignOutDialogOpen}>
|
202 |
+
<AlertDialogContent>
|
203 |
+
<AlertDialogHeader>
|
204 |
+
<AlertDialogTitle>Sign Out</AlertDialogTitle>
|
205 |
+
<AlertDialogDescription>
|
206 |
+
Are you sure you want to sign out? You will need to sign in again to
|
207 |
+
access your account.
|
208 |
+
</AlertDialogDescription>
|
209 |
+
</AlertDialogHeader>
|
210 |
+
<AlertDialogFooter>
|
211 |
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
212 |
+
<AlertDialogAction
|
213 |
+
onClick={handleSignOut}
|
214 |
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
215 |
+
>
|
216 |
+
Sign Out
|
217 |
+
</AlertDialogAction>
|
218 |
+
</AlertDialogFooter>
|
219 |
+
</AlertDialogContent>
|
220 |
+
</AlertDialog>
|
221 |
+
</>
|
222 |
+
);
|
223 |
+
};
|
frontend/src/{pages/SettingsPage.tsx → components/modals/SettingsModal.tsx}
RENAMED
@@ -1,30 +1,49 @@
|
|
1 |
-
import { useState, useRef } from "react";
|
2 |
-
import {
|
3 |
import { Button } from "@/components/ui/button";
|
4 |
-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
5 |
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
6 |
import { Label } from "@/components/ui/label";
|
7 |
-
import { Separator } from "@/components/ui/separator";
|
8 |
import { Input } from "@/components/ui/input";
|
|
|
9 |
import { toast } from "@/components/ui/sonner";
|
10 |
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
|
12 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
const [theme, setTheme] = useState(() => {
|
14 |
return storage.get<string>(STORAGE_KEYS.THEME) || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
15 |
});
|
16 |
-
|
17 |
-
|
18 |
-
storage.get<string>(STORAGE_KEYS.API_ENDPOINT) || "http://localhost:8000"
|
19 |
);
|
20 |
-
|
21 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
const handleThemeChange = (newTheme: string) => {
|
24 |
setTheme(newTheme);
|
25 |
-
|
26 |
const root = window.document.documentElement;
|
27 |
-
|
28 |
if (newTheme === "dark") {
|
29 |
root.classList.add("dark");
|
30 |
} else if (newTheme === "light") {
|
@@ -37,33 +56,31 @@ const SettingsPage = () => {
|
|
37 |
root.classList.remove("dark");
|
38 |
}
|
39 |
}
|
40 |
-
|
41 |
storage.set(STORAGE_KEYS.THEME, newTheme);
|
42 |
toast.success("Theme updated successfully");
|
43 |
};
|
44 |
-
|
45 |
const handleSaveEndpoint = () => {
|
46 |
storage.set(STORAGE_KEYS.API_ENDPOINT, apiEndpoint);
|
47 |
toast.success("API endpoint saved successfully");
|
48 |
};
|
49 |
-
|
50 |
const handleClearChats = () => {
|
51 |
storage.set(STORAGE_KEYS.CHATS, []);
|
52 |
toast.success("Chat history cleared successfully");
|
|
|
53 |
};
|
54 |
-
|
55 |
const handleClearSources = () => {
|
56 |
storage.set(STORAGE_KEYS.SOURCES, []);
|
57 |
toast.success("Sources cleared successfully");
|
58 |
};
|
59 |
-
|
60 |
const handleResetSettings = () => {
|
61 |
-
storage
|
62 |
-
storage.
|
63 |
-
|
64 |
-
setApiEndpoint("http://localhost:8000");
|
65 |
-
setTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
66 |
-
|
67 |
toast.success("Settings reset to defaults");
|
68 |
};
|
69 |
|
@@ -101,172 +118,138 @@ const SettingsPage = () => {
|
|
101 |
reader.readAsText(file);
|
102 |
event.target.value = "";
|
103 |
};
|
104 |
-
|
105 |
return (
|
106 |
-
<
|
107 |
-
<
|
108 |
-
<
|
109 |
-
<
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
<div className="flex items-center space-x-2">
|
138 |
-
<RadioGroupItem value="dark" id="dark" />
|
139 |
-
<Label htmlFor="dark" className="flex items-center cursor-pointer">
|
140 |
-
<Moon className="h-4 w-4 mr-2" />
|
141 |
-
Dark
|
142 |
-
</Label>
|
143 |
-
</div>
|
144 |
-
<div className="flex items-center space-x-2">
|
145 |
-
<RadioGroupItem value="system" id="system" />
|
146 |
-
<Label htmlFor="system" className="flex items-center cursor-pointer">
|
147 |
-
<Globe className="h-4 w-4 mr-2" />
|
148 |
-
System
|
149 |
-
</Label>
|
150 |
-
</div>
|
151 |
-
</RadioGroup>
|
152 |
-
</CardContent>
|
153 |
-
</Card>
|
154 |
-
|
155 |
-
<Card className="glass-effect">
|
156 |
-
<CardHeader className="border-b border-border/40 pb-4">
|
157 |
-
<CardTitle className="flex items-center">
|
158 |
-
<Database className="mr-2 h-4 w-4" />
|
159 |
-
API Configuration
|
160 |
-
</CardTitle>
|
161 |
-
<CardDescription>
|
162 |
-
Configure connection to the financial rulings database
|
163 |
-
</CardDescription>
|
164 |
-
</CardHeader>
|
165 |
-
<CardContent className="space-y-4 pt-4">
|
166 |
-
<div className="space-y-2">
|
167 |
-
<Label htmlFor="api-endpoint">API Endpoint</Label>
|
168 |
-
<div className="flex space-x-2">
|
169 |
-
<Input
|
170 |
-
id="api-endpoint"
|
171 |
-
value={apiEndpoint}
|
172 |
-
onChange={(e) => setApiEndpoint(e.target.value)}
|
173 |
-
placeholder="http://localhost:8000"
|
174 |
-
className="glass-input"
|
175 |
-
/>
|
176 |
-
<Button onClick={handleSaveEndpoint} variant="outline" className="tech-button">
|
177 |
-
<Cloud className="h-4 w-4 mr-2" />
|
178 |
-
Save
|
179 |
-
</Button>
|
180 |
-
</div>
|
181 |
-
</div>
|
182 |
-
</CardContent>
|
183 |
-
</Card>
|
184 |
-
|
185 |
-
<Card className="glass-effect">
|
186 |
-
<CardHeader className="border-b border-border/40 pb-4">
|
187 |
-
<CardTitle className="flex items-center">
|
188 |
-
<Shield className="mr-2 h-4 w-4" />
|
189 |
-
Privacy & Data
|
190 |
-
</CardTitle>
|
191 |
-
<CardDescription>
|
192 |
-
Manage your data and privacy settings
|
193 |
-
</CardDescription>
|
194 |
-
</CardHeader>
|
195 |
-
<CardContent className="pt-4 space-y-4">
|
196 |
-
<div className="space-y-2">
|
197 |
-
<h3 className="text-sm font-medium">Data Management</h3>
|
198 |
-
<div className="flex flex-wrap gap-3">
|
199 |
-
<Button
|
200 |
-
variant="outline"
|
201 |
-
onClick={handleClearChats}
|
202 |
-
className="tech-button"
|
203 |
-
>
|
204 |
-
Clear Chat History
|
205 |
-
</Button>
|
206 |
-
|
207 |
-
<Button
|
208 |
-
variant="outline"
|
209 |
-
onClick={handleClearSources}
|
210 |
-
className="tech-button"
|
211 |
-
>
|
212 |
-
Clear Source References
|
213 |
-
</Button>
|
214 |
-
|
215 |
-
<Button
|
216 |
-
variant="destructive"
|
217 |
-
onClick={handleResetSettings}
|
218 |
-
className="tech-button"
|
219 |
>
|
220 |
-
|
221 |
-
|
|
|
|
|
222 |
</div>
|
223 |
-
|
224 |
-
</
|
225 |
-
</
|
226 |
|
227 |
-
<
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
</
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
className="tech-button"
|
243 |
-
>
|
244 |
-
Export Storage
|
245 |
-
</Button>
|
246 |
-
<Button
|
247 |
-
variant="outline"
|
248 |
-
onClick={() => fileInputRef.current?.click()}
|
249 |
-
className="tech-button"
|
250 |
-
>
|
251 |
-
Import Storage
|
252 |
-
</Button>
|
253 |
-
<input
|
254 |
-
ref={fileInputRef}
|
255 |
-
type="file"
|
256 |
-
accept="application/json"
|
257 |
-
style={{ display: "none" }}
|
258 |
-
onChange={handleImportStorage}
|
259 |
/>
|
|
|
|
|
|
|
|
|
260 |
</div>
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
266 |
</div>
|
267 |
-
</
|
268 |
-
</
|
269 |
);
|
270 |
};
|
271 |
-
|
272 |
-
export default SettingsPage;
|
|
|
1 |
+
import { useState, useRef, useEffect } from "react";
|
2 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
3 |
import { Button } from "@/components/ui/button";
|
|
|
4 |
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
5 |
import { Label } from "@/components/ui/label";
|
|
|
6 |
import { Input } from "@/components/ui/input";
|
7 |
+
import { Separator } from "@/components/ui/separator";
|
8 |
import { toast } from "@/components/ui/sonner";
|
9 |
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
10 |
+
import { X, Settings, Moon, Sun, Globe, Shield, Database, Cloud } from "lucide-react";
|
11 |
+
|
12 |
+
interface SettingsModalProps {
|
13 |
+
open: boolean;
|
14 |
+
onOpenChange: (open: boolean) => void;
|
15 |
+
}
|
16 |
|
17 |
+
const THEME_OPTIONS = [
|
18 |
+
{ value: "light", label: "Light", icon: Sun },
|
19 |
+
{ value: "dark", label: "Dark", icon: Moon },
|
20 |
+
{ value: "system", label: "System", icon: Globe },
|
21 |
+
];
|
22 |
+
|
23 |
+
export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => {
|
24 |
+
// Load settings from storage on mount
|
25 |
const [theme, setTheme] = useState(() => {
|
26 |
return storage.get<string>(STORAGE_KEYS.THEME) || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
27 |
});
|
28 |
+
const [apiEndpoint, setApiEndpoint] = useState(() =>
|
29 |
+
storage.get<string>(STORAGE_KEYS.API_ENDPOINT) || "https://insight-ai-api.hf.space"
|
|
|
30 |
);
|
31 |
+
|
32 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
33 |
|
34 |
+
// Sync settings from storage when modal opens
|
35 |
+
useEffect(() => {
|
36 |
+
if (open) {
|
37 |
+
setTheme(storage.get<string>(STORAGE_KEYS.THEME));
|
38 |
+
setApiEndpoint(storage.get<string>(STORAGE_KEYS.API_ENDPOINT));
|
39 |
+
}
|
40 |
+
}, [open]);
|
41 |
+
|
42 |
const handleThemeChange = (newTheme: string) => {
|
43 |
setTheme(newTheme);
|
44 |
+
|
45 |
const root = window.document.documentElement;
|
46 |
+
|
47 |
if (newTheme === "dark") {
|
48 |
root.classList.add("dark");
|
49 |
} else if (newTheme === "light") {
|
|
|
56 |
root.classList.remove("dark");
|
57 |
}
|
58 |
}
|
59 |
+
|
60 |
storage.set(STORAGE_KEYS.THEME, newTheme);
|
61 |
toast.success("Theme updated successfully");
|
62 |
};
|
63 |
+
|
64 |
const handleSaveEndpoint = () => {
|
65 |
storage.set(STORAGE_KEYS.API_ENDPOINT, apiEndpoint);
|
66 |
toast.success("API endpoint saved successfully");
|
67 |
};
|
68 |
+
|
69 |
const handleClearChats = () => {
|
70 |
storage.set(STORAGE_KEYS.CHATS, []);
|
71 |
toast.success("Chat history cleared successfully");
|
72 |
+
window.location.reload();
|
73 |
};
|
74 |
+
|
75 |
const handleClearSources = () => {
|
76 |
storage.set(STORAGE_KEYS.SOURCES, []);
|
77 |
toast.success("Sources cleared successfully");
|
78 |
};
|
79 |
+
|
80 |
const handleResetSettings = () => {
|
81 |
+
// Reset all keys to their default values using storage lib
|
82 |
+
storage.resetToDefaults();
|
83 |
+
window.location.reload();
|
|
|
|
|
|
|
84 |
toast.success("Settings reset to defaults");
|
85 |
};
|
86 |
|
|
|
118 |
reader.readAsText(file);
|
119 |
event.target.value = "";
|
120 |
};
|
121 |
+
|
122 |
return (
|
123 |
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
124 |
+
<DialogContent className="w-full max-w-3xl">
|
125 |
+
<DialogHeader>
|
126 |
+
<DialogTitle className="flex items-center text-xl">
|
127 |
+
<Settings className="mr-2 h-5 w-5" />
|
128 |
+
Settings
|
129 |
+
</DialogTitle>
|
130 |
+
</DialogHeader>
|
131 |
+
<Separator />
|
132 |
+
<div className="max-h-[80dvh] md:max-h-[75vh] overflow-y-auto grid gap-6">
|
133 |
+
<div className="space-y-4">
|
134 |
+
<h3 className="text-sm font-medium flex items-center">
|
135 |
+
<Moon className="mr-2 h-4 w-4" />
|
136 |
+
Appearance
|
137 |
+
</h3>
|
138 |
+
<RadioGroup
|
139 |
+
value={theme}
|
140 |
+
onValueChange={handleThemeChange}
|
141 |
+
className="grid grid-cols-3 gap-2"
|
142 |
+
>
|
143 |
+
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
|
144 |
+
<div key={value} className="flex flex-col items-center space-y-2">
|
145 |
+
<Label
|
146 |
+
htmlFor={value}
|
147 |
+
className={
|
148 |
+
`flex flex-col items-center justify-center w-full h-20 border rounded-md cursor-pointer transition-colors
|
149 |
+
${theme === value
|
150 |
+
? "bg-financial-accent/30 border-financial-accent/30 ring-2 ring-financial-accent"
|
151 |
+
: "hover:bg-financial-accent/30 hover:border-financial-accent/30"}
|
152 |
+
`
|
153 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
>
|
155 |
+
<Icon className="h-8 w-8 mb-2" />
|
156 |
+
{label}
|
157 |
+
</Label>
|
158 |
+
<RadioGroupItem value={value} id={value} className="sr-only" />
|
159 |
</div>
|
160 |
+
))}
|
161 |
+
</RadioGroup>
|
162 |
+
</div>
|
163 |
|
164 |
+
<Separator />
|
165 |
+
|
166 |
+
<div className="space-y-4">
|
167 |
+
<h3 className="text-sm font-medium flex items-center">
|
168 |
+
<Database className="mr-2 h-4 w-4" />
|
169 |
+
API Configuration
|
170 |
+
</h3>
|
171 |
+
<div className="space-y-2">
|
172 |
+
<Label htmlFor="api-endpoint">API Endpoint</Label>
|
173 |
+
<div className="flex space-x-2">
|
174 |
+
<Input
|
175 |
+
id="api-endpoint"
|
176 |
+
value={apiEndpoint}
|
177 |
+
onChange={(e) => setApiEndpoint(e.target.value)}
|
178 |
+
placeholder="Ex: http://localhost:8000"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
/>
|
180 |
+
<Button onClick={handleSaveEndpoint} variant="outline">
|
181 |
+
<Cloud className="h-4 w-4 mr-2" />
|
182 |
+
Save
|
183 |
+
</Button>
|
184 |
</div>
|
185 |
+
</div>
|
186 |
+
</div>
|
187 |
+
|
188 |
+
<Separator />
|
189 |
+
|
190 |
+
<div className="space-y-4">
|
191 |
+
<h3 className="text-sm font-medium flex items-center">
|
192 |
+
<Shield className="mr-2 h-4 w-4" />
|
193 |
+
Data Management
|
194 |
+
</h3>
|
195 |
+
<div className="flex flex-wrap gap-3">
|
196 |
+
<Button
|
197 |
+
variant="outline"
|
198 |
+
onClick={handleClearChats}
|
199 |
+
>
|
200 |
+
Clear Chat History
|
201 |
+
</Button>
|
202 |
+
|
203 |
+
<Button
|
204 |
+
variant="outline"
|
205 |
+
onClick={handleClearSources}
|
206 |
+
>
|
207 |
+
Clear Sources
|
208 |
+
</Button>
|
209 |
+
|
210 |
+
<Button
|
211 |
+
variant="destructive"
|
212 |
+
onClick={handleResetSettings}
|
213 |
+
>
|
214 |
+
Reset All Settings
|
215 |
+
</Button>
|
216 |
+
</div>
|
217 |
+
</div>
|
218 |
+
|
219 |
+
<Separator />
|
220 |
+
|
221 |
+
<div className="space-y-4">
|
222 |
+
<h3 className="text-sm font-medium flex items-center">
|
223 |
+
<Cloud className="mr-2 h-4 w-4" />
|
224 |
+
Backup & Restore
|
225 |
+
</h3>
|
226 |
+
<div className="flex flex-wrap gap-3">
|
227 |
+
<Button
|
228 |
+
variant="outline"
|
229 |
+
onClick={handleExportStorage}
|
230 |
+
>
|
231 |
+
Export Data
|
232 |
+
</Button>
|
233 |
+
<Button
|
234 |
+
variant="outline"
|
235 |
+
onClick={() => fileInputRef.current?.click()}
|
236 |
+
>
|
237 |
+
Import Data
|
238 |
+
</Button>
|
239 |
+
<input
|
240 |
+
ref={fileInputRef}
|
241 |
+
type="file"
|
242 |
+
accept="application/json"
|
243 |
+
style={{ display: "none" }}
|
244 |
+
onChange={handleImportStorage}
|
245 |
+
/>
|
246 |
+
</div>
|
247 |
+
<div className="text-xs text-muted-foreground">
|
248 |
+
Export will download your data as a JSON file. Import will overwrite your current data.
|
249 |
+
</div>
|
250 |
+
</div>
|
251 |
</div>
|
252 |
+
</DialogContent>
|
253 |
+
</Dialog>
|
254 |
);
|
255 |
};
|
|
|
|
frontend/src/components/modals/SourcesModal.tsx
ADDED
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect } from "react";
|
2 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
3 |
+
import { Button } from "@/components/ui/button";
|
4 |
+
import { Input } from "@/components/ui/input";
|
5 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
6 |
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
7 |
+
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
8 |
+
import { cn } from "@/lib/utils";
|
9 |
+
import { format } from "date-fns";
|
10 |
+
import { DateRange } from "@/types";
|
11 |
+
import { RetrievedSource } from "@/services/apiService";
|
12 |
+
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
13 |
+
import {
|
14 |
+
Search,
|
15 |
+
ArrowUpDown,
|
16 |
+
Calendar,
|
17 |
+
FileText,
|
18 |
+
ChevronDown,
|
19 |
+
ChevronUp,
|
20 |
+
ExternalLink
|
21 |
+
} from "lucide-react";
|
22 |
+
|
23 |
+
interface SourcesModalProps {
|
24 |
+
open: boolean;
|
25 |
+
onOpenChange: (open: boolean) => void;
|
26 |
+
}
|
27 |
+
|
28 |
+
export const SourcesModal = ({ open, onOpenChange }: SourcesModalProps) => {
|
29 |
+
const [searchTerm, setSearchTerm] = useState("");
|
30 |
+
const [selectedSource, setSelectedSource] = useState<string>("");
|
31 |
+
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
32 |
+
const [date, setDate] = useState<DateRange>({
|
33 |
+
from: undefined,
|
34 |
+
to: undefined,
|
35 |
+
});
|
36 |
+
const [sortBy, setSortBy] = useState<string>("date-desc");
|
37 |
+
const [sources, setSources] = useState<RetrievedSource[]>([]);
|
38 |
+
const [sourceTypes, setSourceTypes] = useState<string[]>([]);
|
39 |
+
const [categories, setCategories] = useState<string[]>([]);
|
40 |
+
|
41 |
+
// Load sources from storage
|
42 |
+
useEffect(() => {
|
43 |
+
if (open) {
|
44 |
+
const storedSources = storage.get<RetrievedSource[]>(STORAGE_KEYS.SOURCES) || [];
|
45 |
+
setSources(storedSources);
|
46 |
+
|
47 |
+
// Extract unique source types and categories
|
48 |
+
const types = Array.from(new Set(storedSources.map(s => s.metadata?.source).filter(Boolean)));
|
49 |
+
setSourceTypes(types as string[]);
|
50 |
+
|
51 |
+
const cats = Array.from(new Set(storedSources.map(s => {
|
52 |
+
// For this example, we'll use the first word of the content as a mock category
|
53 |
+
const firstWord = s.content_snippet.split(' ')[0];
|
54 |
+
return firstWord.length > 3 ? firstWord : "General";
|
55 |
+
})));
|
56 |
+
setCategories(cats);
|
57 |
+
}
|
58 |
+
}, [open]);
|
59 |
+
|
60 |
+
// Filter sources based on search term, source, category, and date
|
61 |
+
const filteredSources = sources.filter(source => {
|
62 |
+
const matchesSearch = !searchTerm ||
|
63 |
+
source.content_snippet.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
64 |
+
(source.metadata?.source || "").toLowerCase().includes(searchTerm.toLowerCase());
|
65 |
+
|
66 |
+
const matchesSource = !selectedSource || selectedSource === "All" || source.metadata?.source === selectedSource;
|
67 |
+
|
68 |
+
// Mock category matching based on first word of content
|
69 |
+
const sourceCategory = source.content_snippet.split(' ')[0].length > 3 ?
|
70 |
+
source.content_snippet.split(' ')[0] : "General";
|
71 |
+
const matchesCategory = !selectedCategory || selectedCategory === "All" || sourceCategory === selectedCategory;
|
72 |
+
|
73 |
+
let matchesDate = true;
|
74 |
+
if (date.from && source.metadata?.ruling_date) {
|
75 |
+
matchesDate = matchesDate && new Date(source.metadata.ruling_date) >= date.from;
|
76 |
+
}
|
77 |
+
if (date.to && source.metadata?.ruling_date) {
|
78 |
+
matchesDate = matchesDate && new Date(source.metadata.ruling_date) <= date.to;
|
79 |
+
}
|
80 |
+
|
81 |
+
return matchesSearch && matchesSource && matchesCategory && matchesDate;
|
82 |
+
});
|
83 |
+
|
84 |
+
// Sort sources
|
85 |
+
const sortedSources = [...filteredSources].sort((a, b) => {
|
86 |
+
if (sortBy === "date-desc") {
|
87 |
+
return new Date(b.metadata?.ruling_date || "").getTime() -
|
88 |
+
new Date(a.metadata?.ruling_date || "").getTime();
|
89 |
+
} else if (sortBy === "date-asc") {
|
90 |
+
return new Date(a.metadata?.ruling_date || "").getTime() -
|
91 |
+
new Date(b.metadata?.ruling_date || "").getTime();
|
92 |
+
} else if (sortBy === "relevance-desc") {
|
93 |
+
return b.content_snippet.length - a.content_snippet.length;
|
94 |
+
}
|
95 |
+
return 0;
|
96 |
+
});
|
97 |
+
|
98 |
+
const resetFilters = () => {
|
99 |
+
setSearchTerm("");
|
100 |
+
setSelectedSource("");
|
101 |
+
setSelectedCategory("");
|
102 |
+
setDate({ from: undefined, to: undefined });
|
103 |
+
};
|
104 |
+
|
105 |
+
const [expandedSources, setExpandedSources] = useState<Record<number, boolean>>({});
|
106 |
+
|
107 |
+
const toggleSource = (index: number) => {
|
108 |
+
setExpandedSources(prev => ({
|
109 |
+
...prev,
|
110 |
+
[index]: !prev[index]
|
111 |
+
}));
|
112 |
+
};
|
113 |
+
|
114 |
+
return (
|
115 |
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
116 |
+
<DialogContent className="w-full max-w-4xl">
|
117 |
+
<DialogHeader>
|
118 |
+
<DialogTitle className="flex items-center text-xl">
|
119 |
+
<FileText className="mr-2 h-5 w-5" />
|
120 |
+
Knowledge Sources
|
121 |
+
</DialogTitle>
|
122 |
+
</DialogHeader>
|
123 |
+
|
124 |
+
<div>
|
125 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between mb-2">
|
126 |
+
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2">
|
127 |
+
<Select value={sortBy} onValueChange={setSortBy}>
|
128 |
+
<SelectTrigger className="w-[180px]">
|
129 |
+
<div className="flex items-center">
|
130 |
+
<ArrowUpDown className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
131 |
+
<span>Sort By</span>
|
132 |
+
</div>
|
133 |
+
</SelectTrigger>
|
134 |
+
<SelectContent>
|
135 |
+
<SelectItem value="date-desc">Date (Newest)</SelectItem>
|
136 |
+
<SelectItem value="date-asc">Date (Oldest)</SelectItem>
|
137 |
+
<SelectItem value="relevance-desc">Relevance</SelectItem>
|
138 |
+
</SelectContent>
|
139 |
+
</Select>
|
140 |
+
</div>
|
141 |
+
</div>
|
142 |
+
|
143 |
+
<div className="bg-accent/30 rounded-lg p-1 mb-2">
|
144 |
+
<div className="flex flex-col md:flex-row space-y-1 md:space-y-0 md:space-x-2">
|
145 |
+
<div className="relative flex-1">
|
146 |
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
147 |
+
<Input
|
148 |
+
type="text"
|
149 |
+
placeholder="Search sources..."
|
150 |
+
value={searchTerm}
|
151 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
152 |
+
className="pl-10"
|
153 |
+
/>
|
154 |
+
</div>
|
155 |
+
|
156 |
+
<Select value={selectedSource} onValueChange={setSelectedSource}>
|
157 |
+
<SelectTrigger className="w-full md:w-[180px]">
|
158 |
+
<span className="truncate">
|
159 |
+
{selectedSource || "All Sources"}
|
160 |
+
</span>
|
161 |
+
</SelectTrigger>
|
162 |
+
<SelectContent>
|
163 |
+
<SelectItem value="All">All Sources</SelectItem>
|
164 |
+
{sourceTypes.map(type => (
|
165 |
+
<SelectItem key={type} value={type}>{type}</SelectItem>
|
166 |
+
))}
|
167 |
+
</SelectContent>
|
168 |
+
</Select>
|
169 |
+
|
170 |
+
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
171 |
+
<SelectTrigger className="w-full md:w-[180px]">
|
172 |
+
<span className="truncate">
|
173 |
+
{selectedCategory || "All Categories"}
|
174 |
+
</span>
|
175 |
+
</SelectTrigger>
|
176 |
+
<SelectContent>
|
177 |
+
<SelectItem value="All">All Categories</SelectItem>
|
178 |
+
{categories.map(category => (
|
179 |
+
<SelectItem key={category} value={category}>{category}</SelectItem>
|
180 |
+
))}
|
181 |
+
</SelectContent>
|
182 |
+
</Select>
|
183 |
+
|
184 |
+
<Popover>
|
185 |
+
<PopoverTrigger asChild>
|
186 |
+
<Button variant="outline" className="w-full md:w-[180px] justify-start text-left">
|
187 |
+
<Calendar className="mr-2 h-4 w-4" />
|
188 |
+
<span>
|
189 |
+
{date.from || date.to ? (
|
190 |
+
<>
|
191 |
+
{date.from ? format(date.from, "LLL dd, y") : "From"} - {" "}
|
192 |
+
{date.to ? format(date.to, "LLL dd, y") : "To"}
|
193 |
+
</>
|
194 |
+
) : (
|
195 |
+
"Date Range"
|
196 |
+
)}
|
197 |
+
</span>
|
198 |
+
</Button>
|
199 |
+
</PopoverTrigger>
|
200 |
+
<PopoverContent className="w-auto p-0">
|
201 |
+
<CalendarComponent
|
202 |
+
mode="range"
|
203 |
+
selected={date}
|
204 |
+
onSelect={(value: DateRange | undefined) => {
|
205 |
+
if (value) setDate(value);
|
206 |
+
}}
|
207 |
+
className="p-3"
|
208 |
+
/>
|
209 |
+
</PopoverContent>
|
210 |
+
</Popover>
|
211 |
+
|
212 |
+
<Button
|
213 |
+
variant="outline"
|
214 |
+
className="md:w-auto"
|
215 |
+
onClick={resetFilters}
|
216 |
+
>
|
217 |
+
Reset
|
218 |
+
</Button>
|
219 |
+
</div>
|
220 |
+
</div>
|
221 |
+
|
222 |
+
<div className="max-h-[45dvh] md:max-h-[60vh] overflow-y-auto space-y-4">
|
223 |
+
{sortedSources.length > 0 ? (
|
224 |
+
sortedSources.map((source, index) => (
|
225 |
+
<div key={index} className="border rounded-lg overflow-hidden transition-all duration-300">
|
226 |
+
<div className="p-4 bg-card">
|
227 |
+
<div className="flex items-start justify-between">
|
228 |
+
<div>
|
229 |
+
<h3 className="font-medium">
|
230 |
+
{source.metadata?.source || "Source"}
|
231 |
+
</h3>
|
232 |
+
<div className="flex items-center mt-1 text-sm text-muted-foreground">
|
233 |
+
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
234 |
+
<span>{source.metadata?.source || "Unknown Source"}</span>
|
235 |
+
{source.metadata?.ruling_date && (
|
236 |
+
<>
|
237 |
+
<span className="mx-1.5">•</span>
|
238 |
+
<Calendar className="h-3.5 w-3.5 mr-1.5" />
|
239 |
+
<span>{new Date(source.metadata.ruling_date).toLocaleDateString()}</span>
|
240 |
+
</>
|
241 |
+
)}
|
242 |
+
</div>
|
243 |
+
</div>
|
244 |
+
<Button
|
245 |
+
variant="ghost"
|
246 |
+
size="sm"
|
247 |
+
onClick={() => toggleSource(index)}
|
248 |
+
className="p-0 h-8 w-8 hover:bg-accent/10"
|
249 |
+
>
|
250 |
+
{expandedSources[index] ? (
|
251 |
+
<ChevronUp className="h-4 w-4" />
|
252 |
+
) : (
|
253 |
+
<ChevronDown className="h-4 w-4" />
|
254 |
+
)}
|
255 |
+
</Button>
|
256 |
+
</div>
|
257 |
+
<div className={expandedSources[index] ? "" : "max-h-10 md:max-h-16 overflow-hidden relative"}>
|
258 |
+
<p className="text-sm text-muted-foreground mt-2">
|
259 |
+
{source.content_snippet}
|
260 |
+
</p>
|
261 |
+
{!expandedSources[index] && (
|
262 |
+
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-card to-transparent"></div>
|
263 |
+
)}
|
264 |
+
</div>
|
265 |
+
{expandedSources[index] && (
|
266 |
+
<div className="mt-4 text-sm flex justify-end">
|
267 |
+
<Button variant="link" size="sm" className="h-8 p-0 text-primary">
|
268 |
+
<ExternalLink className="h-3 w-3 mr-1" />
|
269 |
+
View source
|
270 |
+
</Button>
|
271 |
+
</div>
|
272 |
+
)}
|
273 |
+
</div>
|
274 |
+
</div>
|
275 |
+
))
|
276 |
+
) : (
|
277 |
+
<div className="text-center p-8">
|
278 |
+
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted/30 flex items-center justify-center">
|
279 |
+
<FileText className="h-8 w-8 text-muted-foreground" />
|
280 |
+
</div>
|
281 |
+
<h3 className="text-lg font-medium mb-2">No sources found</h3>
|
282 |
+
<p className="text-muted-foreground">
|
283 |
+
{sources.length > 0
|
284 |
+
? "No sources match your current search criteria. Try adjusting your filters."
|
285 |
+
: "Chat with Insight AI to get information with source citations."}
|
286 |
+
</p>
|
287 |
+
<Button
|
288 |
+
variant="outline"
|
289 |
+
className="mt-4"
|
290 |
+
onClick={resetFilters}
|
291 |
+
>
|
292 |
+
Reset Filters
|
293 |
+
</Button>
|
294 |
+
</div>
|
295 |
+
)}
|
296 |
+
</div>
|
297 |
+
</div>
|
298 |
+
</DialogContent>
|
299 |
+
</Dialog>
|
300 |
+
);
|
301 |
+
};
|
frontend/src/index.css
CHANGED
@@ -1,10 +1,34 @@
|
|
1 |
-
|
2 |
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
3 |
|
4 |
@tailwind base;
|
5 |
@tailwind components;
|
6 |
@tailwind utilities;
|
7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
@layer base {
|
9 |
:root {
|
10 |
--background: 222 45% 98%;
|
@@ -176,6 +200,122 @@
|
|
176 |
.message-bubble-user {
|
177 |
border-top-right-radius: 4px;
|
178 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
|
180 |
.typing-indicator span {
|
181 |
@apply inline-block h-2 w-2 rounded-full bg-current;
|
@@ -297,3 +437,96 @@ pre code {
|
|
297 |
transform: translateX(100%);
|
298 |
transition: transform 0.5s;
|
299 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
2 |
|
3 |
@tailwind base;
|
4 |
@tailwind components;
|
5 |
@tailwind utilities;
|
6 |
|
7 |
+
@layer utilities {
|
8 |
+
/* Custom scrollbar utility */
|
9 |
+
.scrollbar-thin {
|
10 |
+
scrollbar-width: thin;
|
11 |
+
}
|
12 |
+
|
13 |
+
.scrollbar-thin::-webkit-scrollbar {
|
14 |
+
width: 5px;
|
15 |
+
height: 5px;
|
16 |
+
}
|
17 |
+
|
18 |
+
.scrollbar-thin::-webkit-scrollbar-track {
|
19 |
+
background: transparent;
|
20 |
+
}
|
21 |
+
|
22 |
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
23 |
+
background: rgba(var(--muted-foreground), 0.3);
|
24 |
+
border-radius: 9999px;
|
25 |
+
}
|
26 |
+
|
27 |
+
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
28 |
+
background: rgba(var(--muted-foreground), 0.5);
|
29 |
+
}
|
30 |
+
}
|
31 |
+
|
32 |
@layer base {
|
33 |
:root {
|
34 |
--background: 222 45% 98%;
|
|
|
200 |
.message-bubble-user {
|
201 |
border-top-right-radius: 4px;
|
202 |
}
|
203 |
+
/* Thinking animation */
|
204 |
+
.thinking-brain {
|
205 |
+
position: relative;
|
206 |
+
width: 32px;
|
207 |
+
height: 32px;
|
208 |
+
margin-bottom: 4px;
|
209 |
+
display: flex;
|
210 |
+
align-items: center;
|
211 |
+
justify-content: center;
|
212 |
+
}
|
213 |
+
|
214 |
+
.thinking-brain-svg {
|
215 |
+
z-index: 10;
|
216 |
+
position: relative;
|
217 |
+
}
|
218 |
+
|
219 |
+
.thinking-waves {
|
220 |
+
position: absolute;
|
221 |
+
top: 0;
|
222 |
+
left: 0;
|
223 |
+
right: 0;
|
224 |
+
bottom: 0;
|
225 |
+
display: flex;
|
226 |
+
justify-content: center;
|
227 |
+
align-items: center;
|
228 |
+
}
|
229 |
+
|
230 |
+
.thinking-waves span {
|
231 |
+
position: absolute;
|
232 |
+
border: 1px solid var(--financial-accent, #7C3AED);
|
233 |
+
border-radius: 50%;
|
234 |
+
opacity: 0;
|
235 |
+
animation: thinking-wave 2s ease-out infinite;
|
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;
|
254 |
+
}
|
255 |
+
|
256 |
+
@keyframes thinking-wave {
|
257 |
+
0% {
|
258 |
+
transform: scale(0.5);
|
259 |
+
opacity: 0.1;
|
260 |
+
}
|
261 |
+
50% {
|
262 |
+
opacity: 0.3;
|
263 |
+
}
|
264 |
+
100% {
|
265 |
+
transform: scale(1.2);
|
266 |
+
opacity: 0;
|
267 |
+
}
|
268 |
+
}
|
269 |
+
|
270 |
+
.thinking-dots-container {
|
271 |
+
display: inline-flex;
|
272 |
+
align-items: center;
|
273 |
+
}
|
274 |
+
|
275 |
+
.thinking-dot {
|
276 |
+
width: 4px;
|
277 |
+
height: 4px;
|
278 |
+
border-radius: 50%;
|
279 |
+
background-color: var(--muted-foreground);
|
280 |
+
margin: 0 2px;
|
281 |
+
opacity: 0.3;
|
282 |
+
transition: opacity 0.2s ease;
|
283 |
+
}
|
284 |
+
|
285 |
+
.thinking-dot-active {
|
286 |
+
opacity: 1;
|
287 |
+
}
|
288 |
+
|
289 |
+
/* Variations UI */
|
290 |
+
.variations-container {
|
291 |
+
margin-top: 0.5rem;
|
292 |
+
border-top: 1px solid var(--border);
|
293 |
+
padding-top: 0.5rem;
|
294 |
+
}
|
295 |
+
|
296 |
+
.variation-tabs {
|
297 |
+
display: flex;
|
298 |
+
gap: 0.5rem;
|
299 |
+
}
|
300 |
+
|
301 |
+
.variation-tab {
|
302 |
+
font-size: 0.75rem;
|
303 |
+
padding: 0.25rem 0.5rem;
|
304 |
+
border-radius: 0.25rem;
|
305 |
+
border: 1px solid var(--border);
|
306 |
+
cursor: pointer;
|
307 |
+
transition: background-color 0.2s, border-color 0.2s;
|
308 |
+
}
|
309 |
+
|
310 |
+
.variation-tab:hover {
|
311 |
+
background-color: var(--accent);
|
312 |
+
}
|
313 |
+
|
314 |
+
.variation-tab.active {
|
315 |
+
background-color: var(--financial-accent);
|
316 |
+
color: white;
|
317 |
+
border-color: var(--financial-accent);
|
318 |
+
}
|
319 |
|
320 |
.typing-indicator span {
|
321 |
@apply inline-block h-2 w-2 rounded-full bg-current;
|
|
|
437 |
transform: translateX(100%);
|
438 |
transition: transform 0.5s;
|
439 |
}
|
440 |
+
|
441 |
+
/* Chat bubble styles */
|
442 |
+
.thinking-dot {
|
443 |
+
@apply inline-block rounded-full bg-financial-accent;
|
444 |
+
opacity: 0.7;
|
445 |
+
}
|
446 |
+
|
447 |
+
@keyframes pulse {
|
448 |
+
0%, 100% { transform: scale(0.8); opacity: 0.5; }
|
449 |
+
50% { transform: scale(1.2); opacity: 0.8; }
|
450 |
+
}
|
451 |
+
|
452 |
+
.animate-pulse {
|
453 |
+
animation: pulse 1.5s infinite ease-in-out;
|
454 |
+
}
|
455 |
+
|
456 |
+
/* Thinking text styles */
|
457 |
+
.thinking-text {
|
458 |
+
@apply bg-gradient-to-r from-financial-accent to-financial-light-accent bg-clip-text text-transparent px-2 py-1 rounded;
|
459 |
+
animation: thinking-glow 2s infinite alternate;
|
460 |
+
}
|
461 |
+
|
462 |
+
@keyframes thinking-glow {
|
463 |
+
from { box-shadow: 0 0 5px rgba(var(--accent), 0.2); }
|
464 |
+
to { box-shadow: 0 0 15px rgba(var(--accent), 0.5); }
|
465 |
+
}
|
466 |
+
|
467 |
+
/* Think tag styling */
|
468 |
+
.think-block {
|
469 |
+
@apply bg-stone-50/70 dark:bg-stone-900/50 border-l-4 border-financial-accent/50 p-3 my-2 rounded-md italic text-muted-foreground;
|
470 |
+
position: relative;
|
471 |
+
overflow: hidden;
|
472 |
+
backdrop-filter: blur(6px);
|
473 |
+
}
|
474 |
+
|
475 |
+
.think-block::before {
|
476 |
+
content: '';
|
477 |
+
position: absolute;
|
478 |
+
left: 0;
|
479 |
+
top: 0;
|
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 {
|
487 |
+
content: '💭';
|
488 |
+
position: absolute;
|
489 |
+
right: 10px;
|
490 |
+
bottom: 5px;
|
491 |
+
opacity: 0.2;
|
492 |
+
font-size: 1.5rem;
|
493 |
+
}
|
494 |
+
|
495 |
+
/* Message variations styling */
|
496 |
+
.variations-container {
|
497 |
+
@apply border-t border-border/30 mt-2 pt-2;
|
498 |
+
}
|
499 |
+
|
500 |
+
.variation-tabs {
|
501 |
+
@apply flex gap-1 overflow-x-auto pb-2 scrollbar-thin;
|
502 |
+
}
|
503 |
+
|
504 |
+
.variation-tab {
|
505 |
+
@apply text-xs px-3 py-1.5 rounded-full bg-muted/50 text-muted-foreground hover:bg-muted transition-all cursor-pointer flex-shrink-0;
|
506 |
+
}
|
507 |
+
|
508 |
+
.variation-tab.active {
|
509 |
+
@apply bg-financial-accent/20 text-financial-accent border border-financial-accent/30 font-medium;
|
510 |
+
}
|
511 |
+
|
512 |
+
@keyframes pulse-border {
|
513 |
+
from { opacity: 0.5; }
|
514 |
+
to { opacity: 1; }
|
515 |
+
}
|
516 |
+
|
517 |
+
/* Regeneration button pulse effect */
|
518 |
+
.regenerate-pulse {
|
519 |
+
animation: regenerate-pulse 2s infinite;
|
520 |
+
}
|
521 |
+
|
522 |
+
@keyframes regenerate-pulse {
|
523 |
+
0% {
|
524 |
+
box-shadow: 0 0 0 0 rgba(var(--accent), 0.4);
|
525 |
+
}
|
526 |
+
70% {
|
527 |
+
box-shadow: 0 0 0 6px rgba(var(--accent), 0);
|
528 |
+
}
|
529 |
+
100% {
|
530 |
+
box-shadow: 0 0 0 0 rgba(var(--accent), 0);
|
531 |
+
}
|
532 |
+
}
|
frontend/src/lib/storage.ts
CHANGED
@@ -10,6 +10,7 @@ export const STORAGE_KEYS = {
|
|
10 |
API_ENDPOINT: 'apiEndpoint',
|
11 |
THEME: 'fis-theme',
|
12 |
SOURCES: 'fis-sources',
|
|
|
13 |
} as const;
|
14 |
|
15 |
// Types for our storage
|
@@ -24,24 +25,40 @@ export type StorageValue = string | object | number | boolean | null | undefined
|
|
24 |
* Currently uses localStorage, but can be extended to use external storage in the future
|
25 |
*/
|
26 |
class StorageService {
|
27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
get<T = any>(key: string): T | null {
|
29 |
try {
|
30 |
const item = localStorage.getItem(key);
|
31 |
-
if (!item)
|
32 |
-
|
33 |
-
|
|
|
|
|
|
|
|
|
34 |
const { value, expires } = JSON.parse(item);
|
35 |
-
|
36 |
-
// Check if the value has expired
|
37 |
if (expires && expires < Date.now()) {
|
38 |
this.remove(key);
|
|
|
|
|
|
|
|
|
39 |
return null;
|
40 |
}
|
41 |
-
|
42 |
return value as T;
|
43 |
} catch (error) {
|
44 |
console.error(`Error getting item from storage: ${key}`, error);
|
|
|
|
|
|
|
|
|
45 |
return null;
|
46 |
}
|
47 |
}
|
@@ -131,7 +148,27 @@ class StorageService {
|
|
131 |
return false;
|
132 |
}
|
133 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
134 |
}
|
135 |
|
136 |
// Create and export a singleton instance
|
137 |
export const storage = new StorageService();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
API_ENDPOINT: 'apiEndpoint',
|
11 |
THEME: 'fis-theme',
|
12 |
SOURCES: 'fis-sources',
|
13 |
+
PROFILE_STORAGE_KEY: 'fis-profile'
|
14 |
} as const;
|
15 |
|
16 |
// Types for our storage
|
|
|
25 |
* Currently uses localStorage, but can be extended to use external storage in the future
|
26 |
*/
|
27 |
class StorageService {
|
28 |
+
private defaults: Partial<Record<string, StorageValue>> = {};
|
29 |
+
|
30 |
+
// Set default values for storage keys
|
31 |
+
setDefaults(defaults: Partial<Record<string, StorageValue>>) {
|
32 |
+
this.defaults = defaults;
|
33 |
+
}
|
34 |
+
|
35 |
+
// Get item from storage with automatic parsing and default fallback
|
36 |
get<T = any>(key: string): T | null {
|
37 |
try {
|
38 |
const item = localStorage.getItem(key);
|
39 |
+
if (!item) {
|
40 |
+
// Return default if available
|
41 |
+
if (key in this.defaults) {
|
42 |
+
return this.defaults[key] as T;
|
43 |
+
}
|
44 |
+
return null;
|
45 |
+
}
|
46 |
const { value, expires } = JSON.parse(item);
|
|
|
|
|
47 |
if (expires && expires < Date.now()) {
|
48 |
this.remove(key);
|
49 |
+
// Return default if available
|
50 |
+
if (key in this.defaults) {
|
51 |
+
return this.defaults[key] as T;
|
52 |
+
}
|
53 |
return null;
|
54 |
}
|
|
|
55 |
return value as T;
|
56 |
} catch (error) {
|
57 |
console.error(`Error getting item from storage: ${key}`, error);
|
58 |
+
// Return default if available
|
59 |
+
if (key in this.defaults) {
|
60 |
+
return this.defaults[key] as T;
|
61 |
+
}
|
62 |
return null;
|
63 |
}
|
64 |
}
|
|
|
148 |
return false;
|
149 |
}
|
150 |
}
|
151 |
+
|
152 |
+
// Reset all keys to their default values
|
153 |
+
resetToDefaults(): boolean {
|
154 |
+
try {
|
155 |
+
Object.entries(this.defaults).forEach(([key, value]) => {
|
156 |
+
this.set(key, value);
|
157 |
+
});
|
158 |
+
return true;
|
159 |
+
} catch (error) {
|
160 |
+
console.error('Error resetting storage to defaults', error);
|
161 |
+
return false;
|
162 |
+
}
|
163 |
+
}
|
164 |
}
|
165 |
|
166 |
// Create and export a singleton instance
|
167 |
export const storage = new StorageService();
|
168 |
+
storage.setDefaults({
|
169 |
+
[STORAGE_KEYS.CHATS]: [],
|
170 |
+
[STORAGE_KEYS.SETTINGS]: {},
|
171 |
+
[STORAGE_KEYS.API_ENDPOINT]: "https://insight-ai-api.hf.space",
|
172 |
+
[STORAGE_KEYS.THEME]: "dark",
|
173 |
+
[STORAGE_KEYS.SOURCES]: [],
|
174 |
+
});
|
frontend/src/pages/Index.tsx
DELETED
@@ -1,8 +0,0 @@
|
|
1 |
-
|
2 |
-
import { Navigate } from "react-router-dom";
|
3 |
-
|
4 |
-
const Index = () => {
|
5 |
-
return <Navigate to="/" replace />;
|
6 |
-
};
|
7 |
-
|
8 |
-
export default Index;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/SourcesPage.tsx
DELETED
@@ -1,286 +0,0 @@
|
|
1 |
-
|
2 |
-
import { useState, useEffect } from "react";
|
3 |
-
import { FileText, Search, Filter, Calendar, ArrowUpDown, ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
|
4 |
-
import { Button } from "@/components/ui/button";
|
5 |
-
import { Input } from "@/components/ui/input";
|
6 |
-
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
7 |
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
8 |
-
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
9 |
-
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
10 |
-
import { cn } from "@/lib/utils";
|
11 |
-
import { Separator } from "@/components/ui/separator";
|
12 |
-
import { format } from "date-fns";
|
13 |
-
import { DateRange } from "@/types";
|
14 |
-
import { RetrievedSource } from "@/services/rulingService";
|
15 |
-
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
16 |
-
|
17 |
-
const SourcesPage = () => {
|
18 |
-
const [searchTerm, setSearchTerm] = useState("");
|
19 |
-
const [selectedSource, setSelectedSource] = useState<string>("");
|
20 |
-
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
21 |
-
const [date, setDate] = useState<DateRange>({
|
22 |
-
from: undefined,
|
23 |
-
to: undefined,
|
24 |
-
});
|
25 |
-
const [sortBy, setSortBy] = useState<string>("date-desc");
|
26 |
-
const [sources, setSources] = useState<RetrievedSource[]>([]);
|
27 |
-
const [sourceTypes, setSourceTypes] = useState<string[]>([]);
|
28 |
-
const [categories, setCategories] = useState<string[]>([]);
|
29 |
-
|
30 |
-
// Load sources from storage
|
31 |
-
useEffect(() => {
|
32 |
-
const storedSources = storage.get<RetrievedSource[]>(STORAGE_KEYS.SOURCES) || [];
|
33 |
-
setSources(storedSources);
|
34 |
-
|
35 |
-
// Extract unique source types and categories
|
36 |
-
const types = Array.from(new Set(storedSources.map(s => s.metadata?.source).filter(Boolean)));
|
37 |
-
setSourceTypes(types as string[]);
|
38 |
-
|
39 |
-
const cats = Array.from(new Set(storedSources.map(s => {
|
40 |
-
// For this example, we'll use the first word of the content as a mock category
|
41 |
-
// In a real app, you'd use a proper category field from metadata
|
42 |
-
const firstWord = s.content_snippet.split(' ')[0];
|
43 |
-
return firstWord.length > 3 ? firstWord : "General";
|
44 |
-
})));
|
45 |
-
setCategories(cats);
|
46 |
-
}, []);
|
47 |
-
|
48 |
-
// Filter sources based on search term, source, category, and date
|
49 |
-
const filteredSources = sources.filter(source => {
|
50 |
-
const matchesSearch = !searchTerm ||
|
51 |
-
source.content_snippet.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
52 |
-
(source.metadata?.source || "").toLowerCase().includes(searchTerm.toLowerCase());
|
53 |
-
|
54 |
-
const matchesSource = !selectedSource || source.metadata?.source === selectedSource;
|
55 |
-
|
56 |
-
// Mock category matching based on first word of content
|
57 |
-
const sourceCategory = source.content_snippet.split(' ')[0].length > 3 ?
|
58 |
-
source.content_snippet.split(' ')[0] : "General";
|
59 |
-
const matchesCategory = !selectedCategory || sourceCategory === selectedCategory;
|
60 |
-
|
61 |
-
let matchesDate = true;
|
62 |
-
if (date.from && source.metadata?.ruling_date) {
|
63 |
-
matchesDate = matchesDate && new Date(source.metadata.ruling_date) >= date.from;
|
64 |
-
}
|
65 |
-
if (date.to && source.metadata?.ruling_date) {
|
66 |
-
matchesDate = matchesDate && new Date(source.metadata.ruling_date) <= date.to;
|
67 |
-
}
|
68 |
-
|
69 |
-
return matchesSearch && matchesSource && matchesCategory && matchesDate;
|
70 |
-
});
|
71 |
-
|
72 |
-
// Sort sources
|
73 |
-
const sortedSources = [...filteredSources].sort((a, b) => {
|
74 |
-
if (sortBy === "date-desc") {
|
75 |
-
return new Date(b.metadata?.ruling_date || "").getTime() -
|
76 |
-
new Date(a.metadata?.ruling_date || "").getTime();
|
77 |
-
} else if (sortBy === "date-asc") {
|
78 |
-
return new Date(a.metadata?.ruling_date || "").getTime() -
|
79 |
-
new Date(b.metadata?.ruling_date || "").getTime();
|
80 |
-
} else if (sortBy === "relevance-desc") {
|
81 |
-
return b.content_snippet.length - a.content_snippet.length;
|
82 |
-
}
|
83 |
-
return 0;
|
84 |
-
});
|
85 |
-
|
86 |
-
const resetFilters = () => {
|
87 |
-
setSearchTerm("");
|
88 |
-
setSelectedSource("");
|
89 |
-
setSelectedCategory("");
|
90 |
-
setDate({ from: undefined, to: undefined });
|
91 |
-
};
|
92 |
-
|
93 |
-
const [expandedSources, setExpandedSources] = useState<Record<number, boolean>>({});
|
94 |
-
|
95 |
-
const toggleSource = (index: number) => {
|
96 |
-
setExpandedSources(prev => ({
|
97 |
-
...prev,
|
98 |
-
[index]: !prev[index]
|
99 |
-
}));
|
100 |
-
};
|
101 |
-
|
102 |
-
return (
|
103 |
-
<div className="container mx-auto px-4 py-8 animate-fade-in">
|
104 |
-
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6">
|
105 |
-
<h1 className="text-3xl font-bold mb-4 md:mb-0 text-financial-navy dark:text-white bg-clip-text text-transparent bg-gradient-to-r from-financial-accent to-financial-light-accent">
|
106 |
-
Financial Sources
|
107 |
-
</h1>
|
108 |
-
|
109 |
-
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2">
|
110 |
-
<Select value={sortBy} onValueChange={setSortBy}>
|
111 |
-
<SelectTrigger className="w-[180px] glass-effect">
|
112 |
-
<div className="flex items-center">
|
113 |
-
<ArrowUpDown className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
114 |
-
<span>Sort By</span>
|
115 |
-
</div>
|
116 |
-
</SelectTrigger>
|
117 |
-
<SelectContent>
|
118 |
-
<SelectItem value="date-desc">Date (Newest)</SelectItem>
|
119 |
-
<SelectItem value="date-asc">Date (Oldest)</SelectItem>
|
120 |
-
<SelectItem value="relevance-desc">Relevance</SelectItem>
|
121 |
-
</SelectContent>
|
122 |
-
</Select>
|
123 |
-
</div>
|
124 |
-
</div>
|
125 |
-
|
126 |
-
<div className="glass-effect rounded-lg p-6 mb-8 shadow-lg">
|
127 |
-
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2">
|
128 |
-
<div className="relative flex-1">
|
129 |
-
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
130 |
-
<Input
|
131 |
-
type="text"
|
132 |
-
placeholder="Search sources..."
|
133 |
-
value={searchTerm}
|
134 |
-
onChange={(e) => setSearchTerm(e.target.value)}
|
135 |
-
className="pl-10 glass-input"
|
136 |
-
/>
|
137 |
-
</div>
|
138 |
-
|
139 |
-
<Select value={selectedSource} onValueChange={setSelectedSource}>
|
140 |
-
<SelectTrigger className="w-full md:w-[180px] glass-input">
|
141 |
-
<span className="truncate">
|
142 |
-
{selectedSource || "All Sources"}
|
143 |
-
</span>
|
144 |
-
</SelectTrigger>
|
145 |
-
<SelectContent>
|
146 |
-
<SelectItem value="All">All Sources</SelectItem>
|
147 |
-
{sourceTypes.map(type => (
|
148 |
-
<SelectItem key={type} value={type}>{type}</SelectItem>
|
149 |
-
))}
|
150 |
-
</SelectContent>
|
151 |
-
</Select>
|
152 |
-
|
153 |
-
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
154 |
-
<SelectTrigger className="w-full md:w-[180px] glass-input">
|
155 |
-
<span className="truncate">
|
156 |
-
{selectedCategory || "All Categories"}
|
157 |
-
</span>
|
158 |
-
</SelectTrigger>
|
159 |
-
<SelectContent>
|
160 |
-
<SelectItem value="All">All Categories</SelectItem>
|
161 |
-
{categories.map(category => (
|
162 |
-
<SelectItem key={category} value={category}>{category}</SelectItem>
|
163 |
-
))}
|
164 |
-
</SelectContent>
|
165 |
-
</Select>
|
166 |
-
|
167 |
-
<Popover>
|
168 |
-
<PopoverTrigger asChild>
|
169 |
-
<Button variant="outline" className="w-full md:w-[180px] justify-start text-left glass-input">
|
170 |
-
<Calendar className="mr-2 h-4 w-4" />
|
171 |
-
<span>
|
172 |
-
{date.from || date.to ? (
|
173 |
-
<>
|
174 |
-
{date.from ? format(date.from, "LLL dd, y") : "From"} - {" "}
|
175 |
-
{date.to ? format(date.to, "LLL dd, y") : "To"}
|
176 |
-
</>
|
177 |
-
) : (
|
178 |
-
"Date Range"
|
179 |
-
)}
|
180 |
-
</span>
|
181 |
-
</Button>
|
182 |
-
</PopoverTrigger>
|
183 |
-
<PopoverContent className="w-auto p-0">
|
184 |
-
<CalendarComponent
|
185 |
-
mode="range"
|
186 |
-
selected={date}
|
187 |
-
onSelect={(value: DateRange | undefined) => {
|
188 |
-
if (value) setDate(value);
|
189 |
-
}}
|
190 |
-
className={cn("p-3 pointer-events-auto")}
|
191 |
-
/>
|
192 |
-
</PopoverContent>
|
193 |
-
</Popover>
|
194 |
-
|
195 |
-
<Button
|
196 |
-
variant="outline"
|
197 |
-
className="md:w-auto glass-input"
|
198 |
-
onClick={resetFilters}
|
199 |
-
>
|
200 |
-
Reset
|
201 |
-
</Button>
|
202 |
-
</div>
|
203 |
-
</div>
|
204 |
-
|
205 |
-
<div className="space-y-4">
|
206 |
-
{sortedSources.length > 0 ? (
|
207 |
-
sortedSources.map((source, index) => (
|
208 |
-
<Card key={index} className="result-card glass-effect overflow-hidden transition-all duration-300">
|
209 |
-
<CardHeader className="pb-2">
|
210 |
-
<div className="flex items-start justify-between">
|
211 |
-
<div>
|
212 |
-
<CardTitle className="text-lg text-gradient">
|
213 |
-
{source.metadata?.source || "Financial Ruling"}
|
214 |
-
</CardTitle>
|
215 |
-
<div className="flex items-center mt-1 text-sm text-muted-foreground">
|
216 |
-
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
217 |
-
<span>{source.metadata?.source || "Unknown Source"}</span>
|
218 |
-
{source.metadata?.ruling_date && (
|
219 |
-
<>
|
220 |
-
<span className="mx-1.5">•</span>
|
221 |
-
<Calendar className="h-3.5 w-3.5 mr-1.5" />
|
222 |
-
<span>{new Date(source.metadata.ruling_date).toLocaleDateString()}</span>
|
223 |
-
</>
|
224 |
-
)}
|
225 |
-
</div>
|
226 |
-
</div>
|
227 |
-
<Button
|
228 |
-
variant="ghost"
|
229 |
-
size="sm"
|
230 |
-
onClick={() => toggleSource(index)}
|
231 |
-
className="p-0 h-8 w-8 hover:bg-accent/10"
|
232 |
-
>
|
233 |
-
{expandedSources[index] ? (
|
234 |
-
<ChevronUp className="h-4 w-4" />
|
235 |
-
) : (
|
236 |
-
<ChevronDown className="h-4 w-4" />
|
237 |
-
)}
|
238 |
-
</Button>
|
239 |
-
</div>
|
240 |
-
</CardHeader>
|
241 |
-
<CardContent className={expandedSources[index] ? "pb-2" : "max-h-24 overflow-hidden relative"}>
|
242 |
-
<p className="text-sm text-muted-foreground">
|
243 |
-
{expandedSources[index]
|
244 |
-
? source.content_snippet
|
245 |
-
: source.content_snippet.substring(0, 150) + "..."}
|
246 |
-
</p>
|
247 |
-
{!expandedSources[index] && (
|
248 |
-
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-card to-transparent"></div>
|
249 |
-
)}
|
250 |
-
</CardContent>
|
251 |
-
{expandedSources[index] && (
|
252 |
-
<CardFooter className="pt-0 text-sm flex justify-end">
|
253 |
-
<Button variant="link" size="sm" className="h-8 p-0 text-financial-accent">
|
254 |
-
<ExternalLink className="h-3 w-3 mr-1" />
|
255 |
-
View source
|
256 |
-
</Button>
|
257 |
-
</CardFooter>
|
258 |
-
)}
|
259 |
-
</Card>
|
260 |
-
))
|
261 |
-
) : (
|
262 |
-
<div className="glass-effect rounded-lg p-8 text-center shadow-lg">
|
263 |
-
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted/30 flex items-center justify-center">
|
264 |
-
<FileText className="h-8 w-8 text-muted-foreground" />
|
265 |
-
</div>
|
266 |
-
<h3 className="text-lg font-medium mb-2 text-gradient">No sources found</h3>
|
267 |
-
<p className="text-muted-foreground">
|
268 |
-
{sources.length > 0
|
269 |
-
? "No sources match your current search criteria. Try adjusting your filters."
|
270 |
-
: "Chat with Insight AI to get information with source citations."}
|
271 |
-
</p>
|
272 |
-
<Button
|
273 |
-
variant="outline"
|
274 |
-
className="mt-4 glass-input"
|
275 |
-
onClick={resetFilters}
|
276 |
-
>
|
277 |
-
Reset Filters
|
278 |
-
</Button>
|
279 |
-
</div>
|
280 |
-
)}
|
281 |
-
</div>
|
282 |
-
</div>
|
283 |
-
);
|
284 |
-
};
|
285 |
-
|
286 |
-
export default SourcesPage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/services/{rulingService.ts → apiService.ts}
RENAMED
@@ -32,25 +32,22 @@ export interface QueryRequest {
|
|
32 |
query: string;
|
33 |
chat_history?: Message[];
|
34 |
filters?: Record<string, any>;
|
|
|
35 |
}
|
36 |
|
37 |
// Add a delay function for better UX when showing loading states
|
38 |
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
39 |
|
40 |
-
export const
|
41 |
async getApiUrl(): Promise<string> {
|
42 |
-
// Get API URL from storage or use default
|
43 |
-
let API_URL = "http://localhost:8000";
|
44 |
const storedUrl = storage.get<string>(STORAGE_KEYS.API_ENDPOINT);
|
45 |
|
46 |
if (storedUrl) {
|
47 |
-
|
48 |
} else {
|
49 |
// Set default if not found
|
50 |
-
|
51 |
}
|
52 |
-
|
53 |
-
return API_URL;
|
54 |
},
|
55 |
|
56 |
async queryRulings(request: QueryRequest): Promise<QueryResponse> {
|
@@ -60,6 +57,12 @@ export const rulingService = {
|
|
60 |
|
61 |
const API_URL = await this.getApiUrl();
|
62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
63 |
const response = await fetch(`${API_URL}/query`, {
|
64 |
method: "POST",
|
65 |
headers: {
|
@@ -101,8 +104,8 @@ export const rulingService = {
|
|
101 |
|
102 |
return result;
|
103 |
} catch (error) {
|
104 |
-
console.error("Failed to query
|
105 |
-
const errorMessage = error instanceof Error ? error.message : "Failed to
|
106 |
toast.error(errorMessage);
|
107 |
throw error;
|
108 |
}
|
@@ -112,6 +115,11 @@ export const rulingService = {
|
|
112 |
try {
|
113 |
const API_URL = await this.getApiUrl();
|
114 |
|
|
|
|
|
|
|
|
|
|
|
115 |
const response = await fetch(`${API_URL}/generate-title`, {
|
116 |
method: "POST",
|
117 |
headers: {
|
|
|
32 |
query: string;
|
33 |
chat_history?: Message[];
|
34 |
filters?: Record<string, any>;
|
35 |
+
regenerate?: boolean;
|
36 |
}
|
37 |
|
38 |
// Add a delay function for better UX when showing loading states
|
39 |
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
40 |
|
41 |
+
export const apiService = {
|
42 |
async getApiUrl(): Promise<string> {
|
|
|
|
|
43 |
const storedUrl = storage.get<string>(STORAGE_KEYS.API_ENDPOINT);
|
44 |
|
45 |
if (storedUrl) {
|
46 |
+
return storedUrl;
|
47 |
} else {
|
48 |
// Set default if not found
|
49 |
+
return null;
|
50 |
}
|
|
|
|
|
51 |
},
|
52 |
|
53 |
async queryRulings(request: QueryRequest): Promise<QueryResponse> {
|
|
|
57 |
|
58 |
const API_URL = await this.getApiUrl();
|
59 |
|
60 |
+
if (!API_URL) {
|
61 |
+
return {
|
62 |
+
answer: "<think>The user hasn't configured an API endpoint yet. I should provide a helpful response.</think>\n\nIt looks like you haven't configured the API endpoint yet. Please go to Settings and enter your API URL to connect to your data source."
|
63 |
+
};
|
64 |
+
}
|
65 |
+
|
66 |
const response = await fetch(`${API_URL}/query`, {
|
67 |
method: "POST",
|
68 |
headers: {
|
|
|
104 |
|
105 |
return result;
|
106 |
} catch (error) {
|
107 |
+
console.error("Failed to query AI:", error);
|
108 |
+
const errorMessage = error instanceof Error ? error.message : "Failed to get a response";
|
109 |
toast.error(errorMessage);
|
110 |
throw error;
|
111 |
}
|
|
|
115 |
try {
|
116 |
const API_URL = await this.getApiUrl();
|
117 |
|
118 |
+
// If no API URL is configured, return a generic title
|
119 |
+
if (!API_URL) {
|
120 |
+
return { title: query.slice(0, 30) + (query.length > 30 ? '...' : '') };
|
121 |
+
}
|
122 |
+
|
123 |
const response = await fetch(`${API_URL}/generate-title`, {
|
124 |
method: "POST",
|
125 |
headers: {
|
frontend/src/types/chat.ts
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
export interface Message {
|
3 |
+
id: string;
|
4 |
+
content: string;
|
5 |
+
sender: "user" | "system";
|
6 |
+
timestamp: Date;
|
7 |
+
isLoading?: boolean;
|
8 |
+
error?: boolean;
|
9 |
+
result?: any;
|
10 |
+
variations?: { id: string, content: string, timestamp: Date }[];
|
11 |
+
activeVariation?: string;
|
12 |
+
}
|
13 |
+
|
14 |
+
export interface Chat {
|
15 |
+
id: string;
|
16 |
+
title: string;
|
17 |
+
messages: Message[];
|
18 |
+
createdAt: Date;
|
19 |
+
updatedAt: Date;
|
20 |
+
}
|