Made chat history get data from server side instead of client side local

This commit is contained in:
Harivansh Rathi 2024-12-11 14:15:56 -05:00
parent 30128368d9
commit 94a353993c
9 changed files with 511 additions and 268 deletions

39
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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
</AvatarFallback>
</Avatar>
)}
<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'
? '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]'
)}
>
<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}
>
{finalContent}
</ReactMarkdown>
{message.role === 'assistant' && citations.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 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]}
components={{
pre: ({ node, ...props }) => (
<div className="overflow-auto rounded-lg bg-muted p-4">
<pre {...props} />
</div>
))}
</div>
</>
)}
),
code: ({ node, inline, ...props }) =>
inline ? (
<code className="rounded-sm bg-muted px-1 py-0.5" {...props} />
) : (
<code {...props} />
),
}}
>
{cleanContent}
</ReactMarkdown>
</div>
{message.role === 'assistant' && usedTools.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" />
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,32 +168,24 @@ export function Message({ message }: MessageProps) {
<ThumbsDown className="h-3 w-3" />
</Button>
</Tooltip>
<Tooltip content={messageCopied ? "Copied!" : "Copy message"}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:text-purple-500"
onClick={handleMessageCopy}
>
{messageCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</Tooltip>
</div>
</>
)}
<Tooltip content={messageCopied ? "Copied!" : "Copy message"}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:text-purple-500"
onClick={handleMessageCopy}
>
{messageCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</Tooltip>
</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>
);
}
};

View 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,
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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'>>;
};
};
};
};

View file

@ -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],
}