mirror of
https://github.com/harivansh-afk/RAG-ui.git
synced 2026-04-15 05:02:10 +00:00
Made chat history get data from server side instead of client side local
This commit is contained in:
parent
30128368d9
commit
94a353993c
9 changed files with 511 additions and 268 deletions
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "vite-react-typescript-starter",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@react-three/drei": "^9.120.3",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@supabase/supabase-js": "^2.47.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@types/three": "^0.170.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
|
|
@ -1088,6 +1090,34 @@
|
|||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
|
||||
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz",
|
||||
"integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.0",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-dialog": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.0",
|
||||
"@radix-ui/react-slot": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
|
||||
|
|
@ -2483,6 +2513,15 @@
|
|||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/line-clamp": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz",
|
||||
"integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
"@react-three/drei": "^9.120.3",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@supabase/supabase-js": "^2.47.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@types/three": "^0.170.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
|
|
|
|||
|
|
@ -1,69 +1,47 @@
|
|||
import React from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Copy, Check, BookOpen, Link as LinkIcon, ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Avatar, AvatarFallback } from '../ui/Avatar';
|
||||
import { Tooltip } from '../ui/Tooltip';
|
||||
import { ThumbsUp, ThumbsDown, Copy, Check, User, Bot, Wrench } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback } from '../ui/Avatar';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { ChatMessage } from '../../types/supabase';
|
||||
import type { Components } from 'react-markdown';
|
||||
|
||||
interface MessageProps {
|
||||
message: ChatMessage;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
language: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Citation {
|
||||
text: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function extractCitations(content: string): { cleanContent: string; citations: Citation[] } {
|
||||
const citationRegex = /\[([^\]]+)\]\(((?:(?!\)\[).)+)\)/g;
|
||||
const citations: Citation[] = [];
|
||||
const cleanContent = content.replace(citationRegex, (_, text, source) => {
|
||||
citations.push({ text, source });
|
||||
return text;
|
||||
});
|
||||
return { cleanContent, citations };
|
||||
}
|
||||
|
||||
function extractUsedTool(content: string): { cleanContent: string; usedTools: string[] } {
|
||||
const usedToolRegex = /^\*\*Used Tool(?:s)?: ([^*]+)\*\*\n\n/;
|
||||
const match = content.match(usedToolRegex);
|
||||
function extractUsedTools(content: string): { cleanContent: string; usedTools: string[] } {
|
||||
const toolRegex = /^\*\*Used Tools?:([^*]+)\*\*\n\n/;
|
||||
const match = content.match(toolRegex);
|
||||
|
||||
if (match) {
|
||||
const usedToolsString = match[1];
|
||||
const usedTools = usedToolsString.split(',').map(tool => tool.trim());
|
||||
const cleanContent = content.replace(usedToolRegex, '');
|
||||
const toolsString = match[1].trim();
|
||||
// Simply split by newlines and clean up each line
|
||||
const usedTools = toolsString
|
||||
.split('\n')
|
||||
.map(tool => tool.trim())
|
||||
.filter(tool => tool.length > 0);
|
||||
|
||||
const cleanContent = content.replace(toolRegex, '');
|
||||
return { cleanContent, usedTools };
|
||||
}
|
||||
|
||||
return { cleanContent: content, usedTools: [] };
|
||||
}
|
||||
|
||||
export function Message({ message }: MessageProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
export const Message: React.FC<MessageProps> = ({ message, isLast }) => {
|
||||
const isUser = message.role === 'user';
|
||||
const [messageCopied, setMessageCopied] = React.useState(false);
|
||||
const [isLiked, setIsLiked] = React.useState(false);
|
||||
const [isDisliked, setIsDisliked] = React.useState(false);
|
||||
const { cleanContent, citations } = extractCitations(message.content);
|
||||
const { cleanContent: finalContent, usedTools } = extractUsedTool(cleanContent);
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
const { cleanContent, usedTools } = useMemo(() =>
|
||||
extractUsedTools(message.content), [message.content]
|
||||
);
|
||||
|
||||
const handleMessageCopy = async () => {
|
||||
try {
|
||||
|
|
@ -83,150 +61,74 @@ export function Message({ message }: MessageProps) {
|
|||
setIsDisliked(!isDisliked);
|
||||
setIsLiked(false);
|
||||
}
|
||||
// Here you could send feedback to your backend
|
||||
};
|
||||
|
||||
const CodeBlock = React.memo(({ language, value }: CodeBlockProps) => (
|
||||
<div className="relative my-4 rounded-lg overflow-hidden bg-muted/30 transition-all duration-200 hover:bg-muted/40 group">
|
||||
<div className="absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Tooltip content={copied ? 'Copied!' : 'Copy code'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 bg-background/80 backdrop-blur-sm hover:bg-background/90"
|
||||
onClick={() => handleCopy(value)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="bg-muted/50 backdrop-blur-sm px-4 py-2 text-xs font-medium text-muted-foreground/80 flex items-center justify-between">
|
||||
<span>{language}</span>
|
||||
<span className="text-xs text-muted-foreground/60">{value.split('\n').length} lines</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={vs}
|
||||
customStyle={{ margin: 0, background: 'transparent', padding: '1rem' }}
|
||||
PreTag="div"
|
||||
className="!bg-transparent"
|
||||
>
|
||||
{value.replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
));
|
||||
|
||||
const code: Components['code'] = ({ className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const value = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!className) {
|
||||
return (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return <CodeBlock language={language} value={value} />;
|
||||
};
|
||||
|
||||
const components: Components = {
|
||||
code,
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-xl font-semibold tracking-tight mt-6 mb-4">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-lg font-semibold tracking-tight mt-5 mb-3">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-semibold tracking-tight mt-4 mb-2">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full gap-3 px-4 animate-in fade-in slide-in-from-bottom-4 duration-300',
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
'group flex w-full items-start gap-4 px-4',
|
||||
isUser && 'flex-row-reverse'
|
||||
)}
|
||||
>
|
||||
{message.role === 'assistant' && (
|
||||
<Avatar className="h-8 w-8 ring-2 ring-purple-500/20 transition-all duration-300 group-hover:ring-purple-500/40">
|
||||
<AvatarFallback className="bg-gradient-to-br from-purple-300 to-purple-500 text-white text-xs font-medium">
|
||||
AI
|
||||
<Avatar className={cn(
|
||||
'flex h-8 w-8 shrink-0 select-none overflow-hidden rounded-full',
|
||||
isUser
|
||||
? 'ring-2 ring-secondary/20 hover:ring-secondary/40 transition-all duration-300'
|
||||
: 'ring-2 ring-purple-500/20 hover:ring-purple-500/40 transition-all duration-300'
|
||||
)}>
|
||||
<AvatarFallback className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full text-xs font-medium',
|
||||
isUser
|
||||
? 'bg-secondary text-secondary-foreground'
|
||||
: 'bg-gradient-to-br from-purple-300 to-purple-500 text-white'
|
||||
)}>
|
||||
{isUser ? 'You' : 'AI'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 max-w-3xl w-full">
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-2xl px-4 py-3 text-sm transition-all duration-200',
|
||||
message.role === 'user'
|
||||
<div className={cn('flex flex-col gap-2', isUser ? 'items-end' : 'items-start')}>
|
||||
<div className={cn(
|
||||
'relative rounded-lg px-4 py-3 text-sm transition-all duration-200 max-w-[800px]',
|
||||
isUser
|
||||
? 'bg-gradient-to-r from-purple-400 to-purple-500 text-white shadow-lg shadow-purple-500/20 hover:shadow-purple-500/30 hover:translate-y-[-1px]'
|
||||
: 'bg-background/60 backdrop-blur-sm shadow-[0_4px_20px_-8px_rgba(0,0,0,0.1)] hover:shadow-[0_8px_30px_-12px_rgba(0,0,0,0.15)] hover:bg-background/80 hover:translate-y-[-1px]'
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
<div className={cn(
|
||||
'prose prose-sm max-w-none',
|
||||
isUser ? 'prose-invert' : 'prose-neutral dark:prose-invert',
|
||||
'prose-p:leading-relaxed prose-p:mb-2 last:prose-p:mb-0'
|
||||
)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
className={cn(
|
||||
'prose prose-sm max-w-none',
|
||||
'prose-headings:mb-2 prose-headings:mt-4 first:prose-headings:mt-0',
|
||||
'prose-p:leading-relaxed prose-p:mb-2 last:prose-p:mb-0',
|
||||
'prose-li:my-0',
|
||||
message.role === 'user' ? 'prose-invert' : 'prose-stone dark:prose-invert'
|
||||
)}
|
||||
components={components}
|
||||
components={{
|
||||
pre: ({ node, ...props }) => (
|
||||
<div className="overflow-auto rounded-lg bg-muted p-4">
|
||||
<pre {...props} />
|
||||
</div>
|
||||
),
|
||||
code: ({ node, inline, ...props }) =>
|
||||
inline ? (
|
||||
<code className="rounded-sm bg-muted px-1 py-0.5" {...props} />
|
||||
) : (
|
||||
<code {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{finalContent}
|
||||
{cleanContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{message.role === 'assistant' && citations.length > 0 && (
|
||||
{!isUser && usedTools.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-4 opacity-30" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground/80">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Sources
|
||||
</div>
|
||||
{citations.map((citation, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-xs group/citation">
|
||||
<LinkIcon className="h-3 w-3 mt-0.5 text-muted-foreground/70" />
|
||||
<span className="text-muted-foreground/70 group-hover/citation:text-foreground/90 transition-colors">
|
||||
{citation.text} - <span className="text-foreground/90 underline-offset-4 group-hover/citation:underline">{citation.source}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{message.role === 'assistant' && usedTools.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-4 opacity-30" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground/80">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Used Tools
|
||||
<Separator className="my-2 opacity-30" />
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span className="font-medium">Used Tools:</span>
|
||||
</div>
|
||||
{usedTools.map((tool, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-xs">
|
||||
<LinkIcon className="h-3 w-3 mt-0.5 text-muted-foreground/70" />
|
||||
<span className="text-muted-foreground/70">
|
||||
{tool}
|
||||
</span>
|
||||
<div key={index} className="flex items-center gap-1 pl-4">
|
||||
<span>{tool}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -234,13 +136,12 @@ export function Message({ message }: MessageProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(message.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
|
||||
{message.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity duration-200'
|
||||
)}>
|
||||
{!isUser && (
|
||||
<>
|
||||
<Tooltip content="Helpful">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -267,6 +168,8 @@ export function Message({ message }: MessageProps) {
|
|||
<ThumbsDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip content={messageCopied ? "Copied!" : "Copy message"}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -282,17 +185,7 @@ export function Message({ message }: MessageProps) {
|
|||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.role === 'user' && (
|
||||
<Avatar className="h-8 w-8 ring-2 ring-secondary/20 transition-all duration-300 group-hover:ring-secondary/40">
|
||||
<AvatarFallback className="bg-secondary text-secondary-foreground text-xs font-medium">
|
||||
You
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
138
src/components/ui/AlertDialog.tsx
Normal file
138
src/components/ui/AlertDialog.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-semibold ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ChatInstance, ChatMessage } from '../types/supabase';
|
||||
import type { ChatInstance, ChatMessage, N8NChatHistory } from '../types/supabase';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Helper function to generate UUIDs
|
||||
const generateId = () => crypto.randomUUID();
|
||||
|
|
@ -71,6 +72,39 @@ const sendToN8N = async (sessionId: string, message: string): Promise<N8NRespons
|
|||
}
|
||||
};
|
||||
|
||||
// Helper function to group messages by session and create chat instances
|
||||
const createChatInstanceFromMessages = (messages: N8NChatHistory[]): ChatInstance[] => {
|
||||
const sessionMap = new Map<string, N8NChatHistory[]>();
|
||||
|
||||
// Group messages by session_id
|
||||
messages.forEach(msg => {
|
||||
const existing = sessionMap.get(msg.session_id) || [];
|
||||
sessionMap.set(msg.session_id, [...existing, msg]);
|
||||
});
|
||||
|
||||
// Convert each session group into a ChatInstance
|
||||
return Array.from(sessionMap.entries()).map(([sessionId, messages]) => {
|
||||
const sortedMessages = messages.sort((a, b) => a.id - b.id);
|
||||
const firstMessage = sortedMessages[0];
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
created_at: '',
|
||||
user_id: 'system',
|
||||
title: firstMessage.message.content.slice(0, 50) + '...',
|
||||
last_message_at: '',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface ChatStats {
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
lastMessage: string;
|
||||
humanMessages: number;
|
||||
aiMessages: number;
|
||||
}
|
||||
|
||||
export const chatService = {
|
||||
async createChatInstance(userId: string, title: string): Promise<ChatInstance | null> {
|
||||
try {
|
||||
|
|
@ -122,10 +156,14 @@ export const chatService = {
|
|||
|
||||
async getChatInstances(userId: string): Promise<ChatInstance[]> {
|
||||
try {
|
||||
const chats = getFromStorage<ChatInstance>('chat_instances');
|
||||
return chats
|
||||
.filter(chat => chat.user_id === userId)
|
||||
.sort((a, b) => new Date(b.last_message_at).getTime() - new Date(a.last_message_at).getTime());
|
||||
const { data: messages, error } = await supabase
|
||||
.from('n8n_chat_histories')
|
||||
.select('*')
|
||||
.order('id', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return createChatInstanceFromMessages(messages);
|
||||
} catch (error) {
|
||||
console.error('Error fetching chat instances:', error);
|
||||
return [];
|
||||
|
|
@ -134,10 +172,26 @@ export const chatService = {
|
|||
|
||||
async getChatMessages(chatId: string): Promise<ChatMessage[]> {
|
||||
try {
|
||||
const messages = getFromStorage<ChatMessage>('chat_messages');
|
||||
return messages
|
||||
.filter(message => message.chat_id === chatId)
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
const { data: messages, error } = await supabase
|
||||
.from('n8n_chat_histories')
|
||||
.select('*')
|
||||
.eq('session_id', chatId)
|
||||
.order('id', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Convert N8NChatHistory to ChatMessage format
|
||||
return messages.map(msg => ({
|
||||
id: msg.id.toString(),
|
||||
chat_id: msg.session_id,
|
||||
content: msg.message.content,
|
||||
role: msg.message.type === 'human' ? 'user' : 'assistant',
|
||||
created_at: new Date(msg.id).toISOString(), // Using id as timestamp approximation
|
||||
metadata: {
|
||||
...msg.message.response_metadata,
|
||||
...msg.message.additional_kwargs
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching chat messages:', error);
|
||||
return [];
|
||||
|
|
@ -157,7 +211,7 @@ export const chatService = {
|
|||
chat_id: chatId,
|
||||
content,
|
||||
role,
|
||||
created_at: new Date().toISOString(),
|
||||
created_at: '', // Empty string for timestamp
|
||||
metadata,
|
||||
};
|
||||
|
||||
|
|
@ -178,7 +232,7 @@ export const chatService = {
|
|||
chat_id: chatId,
|
||||
content: n8nResponse.response.content,
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString(),
|
||||
created_at: '', // Empty string for timestamp
|
||||
metadata: {
|
||||
...n8nResponse.response.metadata,
|
||||
make_response_id: userMessage.id // Link to the user's message
|
||||
|
|
@ -200,7 +254,7 @@ export const chatService = {
|
|||
chat_id: chatId,
|
||||
content: 'Sorry, I encountered an error processing your request.',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString(),
|
||||
created_at: '', // Empty string for timestamp
|
||||
metadata: {
|
||||
error: 'Failed to process message',
|
||||
make_response_id: userMessage.id
|
||||
|
|
@ -212,17 +266,9 @@ export const chatService = {
|
|||
}
|
||||
}
|
||||
|
||||
// Update last_message_at in chat instance
|
||||
const chats = getFromStorage<ChatInstance>('chat_instances');
|
||||
const chatIndex = chats.findIndex(chat => chat.id === chatId);
|
||||
if (chatIndex !== -1) {
|
||||
chats[chatIndex].last_message_at = new Date().toISOString();
|
||||
setInStorage('chat_instances', chats);
|
||||
}
|
||||
|
||||
// Return all messages for this chat
|
||||
const updatedMessages = messages.filter(msg => msg.chat_id === chatId)
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
.sort((a, b) => messages.indexOf(a) - messages.indexOf(b)); // Sort by order of addition
|
||||
console.log('Returning updated messages:', updatedMessages);
|
||||
return updatedMessages;
|
||||
} catch (error) {
|
||||
|
|
@ -231,18 +277,40 @@ export const chatService = {
|
|||
}
|
||||
},
|
||||
|
||||
async getChatStats(chatId: string): Promise<ChatStats | null> {
|
||||
try {
|
||||
const { data: messages, error } = await supabase
|
||||
.from('n8n_chat_histories')
|
||||
.select('*')
|
||||
.eq('session_id', chatId)
|
||||
.order('id', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
const stats: ChatStats = {
|
||||
messageCount: messages.length,
|
||||
firstMessage: messages[0].message.content,
|
||||
lastMessage: messages[messages.length - 1].message.content,
|
||||
humanMessages: messages.filter(m => m.message.type === 'human').length,
|
||||
aiMessages: messages.filter(m => m.message.type === 'ai').length
|
||||
};
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('Error getting chat stats:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteChatInstance(chatId: string): Promise<boolean> {
|
||||
try {
|
||||
// Delete all messages for this chat
|
||||
const messages = getFromStorage<ChatMessage>('chat_messages');
|
||||
const filteredMessages = messages.filter(message => message.chat_id !== chatId);
|
||||
setInStorage('chat_messages', filteredMessages);
|
||||
|
||||
// Delete the chat instance
|
||||
const chats = getFromStorage<ChatInstance>('chat_instances');
|
||||
const filteredChats = chats.filter(chat => chat.id !== chatId);
|
||||
setInStorage('chat_instances', filteredChats);
|
||||
const { error } = await supabase
|
||||
.from('n8n_chat_histories')
|
||||
.delete()
|
||||
.eq('session_id', chatId);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting chat instance:', error);
|
||||
|
|
|
|||
|
|
@ -57,23 +57,42 @@ export default function AskQuestion() {
|
|||
try {
|
||||
// Don't try to load chat if we're using a temporary ID
|
||||
if (chatId.startsWith('temp-')) {
|
||||
// For new chats, create a temporary chat instance
|
||||
const tempChat: ChatInstance = {
|
||||
id: chatId,
|
||||
created_at: '',
|
||||
user_id: 'system',
|
||||
title: 'New Chat',
|
||||
last_message_at: '',
|
||||
};
|
||||
setChat(tempChat);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load chat instance
|
||||
const chatInstance = await chatService.getChatInstance(chatId);
|
||||
if (!chatInstance) {
|
||||
setError('Chat not found');
|
||||
return;
|
||||
}
|
||||
setChat(chatInstance);
|
||||
|
||||
// Load messages
|
||||
// Load messages for existing chats
|
||||
const messages = await chatService.getChatMessages(chatId);
|
||||
setMessages(messages);
|
||||
|
||||
// For existing chats, either load from storage or create from messages
|
||||
const existingChat = await chatService.getChatInstance(chatId);
|
||||
if (existingChat) {
|
||||
setChat(existingChat);
|
||||
} else if (messages.length > 0) {
|
||||
// Create a chat instance from the first message
|
||||
const firstMessage = messages[0];
|
||||
const chatInstance: ChatInstance = {
|
||||
id: chatId,
|
||||
created_at: '',
|
||||
user_id: 'system',
|
||||
title: firstMessage.content.slice(0, 50) + '...',
|
||||
last_message_at: '',
|
||||
};
|
||||
setChat(chatInstance);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load chat');
|
||||
console.error('Error loading chat:', err);
|
||||
setError('Failed to load chat');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -109,18 +128,11 @@ export default function AskQuestion() {
|
|||
try {
|
||||
let currentChatId: string;
|
||||
|
||||
// If no chat exists or we're on the base ask page, create a new one
|
||||
if (!chatId || chatId === undefined) {
|
||||
const title = generateChatTitle(question.trim());
|
||||
const newChat = await chatService.createChatInstance(user.id, title);
|
||||
if (!newChat) {
|
||||
throw new Error('Failed to create chat instance');
|
||||
}
|
||||
currentChatId = newChat.id;
|
||||
setChat(newChat);
|
||||
setIsNewChat(false);
|
||||
// If no chat exists or we're starting a new chat, generate a new session ID
|
||||
if (!chatId || chatId.startsWith('temp-')) {
|
||||
currentChatId = crypto.randomUUID();
|
||||
// Update URL with the real chat ID
|
||||
navigate(`/dashboard/ask/${newChat.id}`, { replace: true });
|
||||
navigate(`/dashboard/ask/${currentChatId}`, { replace: true });
|
||||
} else {
|
||||
currentChatId = chatId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,55 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { MessageSquare, Trash2, User, Bot } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { chatService } from '../../lib/chat-service';
|
||||
import type { ChatInstance } from '../../types/supabase';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../../components/ui/AlertDialog";
|
||||
|
||||
interface ChatWithStats extends ChatInstance {
|
||||
stats?: {
|
||||
messageCount: number;
|
||||
humanMessages: number;
|
||||
aiMessages: number;
|
||||
};
|
||||
isLoadingStats?: boolean;
|
||||
}
|
||||
|
||||
export default function StudyHistory() {
|
||||
const { user } = useAuth();
|
||||
const [chats, setChats] = useState<ChatInstance[]>([]);
|
||||
const [chats, setChats] = useState<ChatWithStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [chatToDelete, setChatToDelete] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const loadChats = async () => {
|
||||
const chatInstances = await chatService.getChatInstances(user.id);
|
||||
setChats(chatInstances);
|
||||
setChats(chatInstances.map(chat => ({ ...chat, isLoadingStats: true })));
|
||||
setLoading(false);
|
||||
|
||||
// Load stats for each chat
|
||||
for (const chat of chatInstances) {
|
||||
const stats = await chatService.getChatStats(chat.id);
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === chat.id
|
||||
? { ...c, stats, isLoadingStats: false }
|
||||
: c
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
loadChats();
|
||||
|
|
@ -28,6 +60,7 @@ export default function StudyHistory() {
|
|||
if (success) {
|
||||
setChats(prev => prev.filter(chat => chat.id !== chatId));
|
||||
}
|
||||
setChatToDelete(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -70,13 +103,32 @@ export default function StudyHistory() {
|
|||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className="group relative rounded-lg border bg-card p-4 transition-colors hover:bg-purple-50/50"
|
||||
className="group relative rounded-lg border bg-card p-6 transition-all hover:border-purple-300 hover:shadow-md"
|
||||
>
|
||||
<Link to={`/dashboard/ask/${chat.id}`} className="block">
|
||||
<h3 className="font-medium">{chat.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Last updated: {new Date(chat.last_message_at).toLocaleString()}
|
||||
</p>
|
||||
<h3 className="font-medium line-clamp-2">{chat.title}</h3>
|
||||
|
||||
{chat.isLoadingStats ? (
|
||||
<div className="mt-3 animate-pulse space-y-2">
|
||||
<div className="h-2 w-3/4 rounded bg-gray-200"></div>
|
||||
<div className="h-2 w-1/2 rounded bg-gray-200"></div>
|
||||
</div>
|
||||
) : chat.stats ? (
|
||||
<div className="mt-3 flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center">
|
||||
<MessageSquare className="mr-1 h-4 w-4" />
|
||||
{chat.stats.messageCount}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<User className="mr-1 h-4 w-4" />
|
||||
{chat.stats.humanMessages}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Bot className="mr-1 h-4 w-4" />
|
||||
{chat.stats.aiMessages}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -84,7 +136,7 @@ export default function StudyHistory() {
|
|||
className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete(chat.id);
|
||||
setChatToDelete(chat.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
|
|
@ -93,6 +145,26 @@ export default function StudyHistory() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={!!chatToDelete} onOpenChange={() => setChatToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the chat and all its messages.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={() => chatToDelete && handleDelete(chatToDelete)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export interface ChatInstance {
|
||||
id: string;
|
||||
created_at: string;
|
||||
created_at: string; // Keep this as it's required by Supabase but we'll use empty string
|
||||
user_id: string;
|
||||
title: string;
|
||||
last_message_at: string;
|
||||
last_message_at: string; // Keep this as it's required by Supabase but we'll use empty string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
|
@ -11,13 +11,26 @@ export interface ChatMessage {
|
|||
chat_id: string;
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
created_at: string;
|
||||
created_at: string; // Keep this as it's required by Supabase but we'll use empty string
|
||||
metadata?: {
|
||||
make_response_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface N8NChatHistory {
|
||||
id: number;
|
||||
session_id: string;
|
||||
message: {
|
||||
type: 'human' | 'ai';
|
||||
content: string;
|
||||
tool_calls?: any[];
|
||||
additional_kwargs: Record<string, any>;
|
||||
response_metadata: Record<string, any>;
|
||||
invalid_tool_calls?: any[];
|
||||
};
|
||||
}
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
|
|
@ -31,6 +44,11 @@ export type Database = {
|
|||
Insert: Omit<ChatMessage, 'id' | 'created_at'>;
|
||||
Update: Partial<Omit<ChatMessage, 'id' | 'created_at' | 'chat_id'>>;
|
||||
};
|
||||
n8n_chat_histories: {
|
||||
Row: N8NChatHistory;
|
||||
Insert: Omit<N8NChatHistory, 'id'>;
|
||||
Update: Partial<Omit<N8NChatHistory, 'id'>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
import forms from '@tailwindcss/forms'
|
||||
import scrollbarHide from 'tailwind-scrollbar-hide'
|
||||
import lineClamp from '@tailwindcss/line-clamp'
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
|
|
@ -75,5 +76,5 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
plugins: [forms, scrollbarHide],
|
||||
plugins: [forms, scrollbarHide, lineClamp],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue