mirror of
https://github.com/harivansh-afk/RAG-ui.git
synced 2026-04-15 08:03:45 +00:00
implemented gpt 4 AI interface
This commit is contained in:
parent
e0ad860ab6
commit
88f0bc7a0c
12 changed files with 2891 additions and 110 deletions
1727
package-lock.json
generated
1727
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -24,14 +24,20 @@
|
|||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"openai": "^4.76.0",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"rehype-highlight": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
|
|
|||
15
src/App.tsx
15
src/App.tsx
|
|
@ -5,18 +5,21 @@ import { SessionContextProvider } from '@supabase/auth-helpers-react';
|
|||
import { Toaster } from 'react-hot-toast';
|
||||
import { supabase } from './lib/supabase';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ChatProvider } from './contexts/ChatContext';
|
||||
import './index.css';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<SessionContextProvider supabaseClient={supabase}>
|
||||
<AuthProvider>
|
||||
<Toaster position="top-right" />
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<AppRouter />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
<ChatProvider>
|
||||
<Toaster position="top-right" />
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<AppRouter />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</ChatProvider>
|
||||
</AuthProvider>
|
||||
</SessionContextProvider>
|
||||
);
|
||||
|
|
|
|||
201
src/components/Chat/ChatInterface.tsx
Normal file
201
src/components/Chat/ChatInterface.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useChat } from '../../contexts/ChatContext';
|
||||
import { Message, Resource } from '../../types/chat';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useUser } from '@supabase/auth-helpers-react';
|
||||
|
||||
export function ChatInterface() {
|
||||
const { currentSession, loading, error, sendMessage } = useChat();
|
||||
const [input, setInput] = useState('');
|
||||
const [selectedResources, setSelectedResources] = useState<string[]>([]);
|
||||
const [availableResources, setAvailableResources] = useState<Resource[]>([]);
|
||||
const [resourcesLoading, setResourcesLoading] = useState(true);
|
||||
const [resourceError, setResourceError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const user = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch available resources
|
||||
const fetchResources = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setResourcesLoading(true);
|
||||
setResourceError(null);
|
||||
const { data, error } = await supabase
|
||||
.from('study_resources')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setAvailableResources(data || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching resources:', err);
|
||||
setResourceError('Failed to load study resources');
|
||||
} finally {
|
||||
setResourcesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchResources();
|
||||
}, [user]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [currentSession?.messages]);
|
||||
|
||||
const handleResourceToggle = (resourceId: string) => {
|
||||
console.log('Toggling resource:', resourceId);
|
||||
setSelectedResources(prev => {
|
||||
const newResources = prev.includes(resourceId)
|
||||
? prev.filter(id => id !== resourceId)
|
||||
: [...prev, resourceId];
|
||||
console.log('Selected resources after toggle:', newResources);
|
||||
return newResources;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
console.log('Submitting message with selected resources:', selectedResources);
|
||||
const message = input;
|
||||
setInput('');
|
||||
|
||||
try {
|
||||
await sendMessage(message, selectedResources.length > 0 ? selectedResources : undefined);
|
||||
} catch (err) {
|
||||
console.error('Error sending message:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to send message');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessage = (message: Message) => {
|
||||
const isUser = message.role === 'user';
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg p-4 ${
|
||||
isUser
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
{message.resourceIds && message.resourceIds.length > 0 && (
|
||||
<div className="mt-2 text-sm opacity-75">
|
||||
Referenced resources: {message.resourceIds.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{currentSession?.messages.map(renderMessage)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-100 dark:bg-red-900 text-red-900 dark:text-red-100">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-2 border-t dark:border-gray-700">
|
||||
{/* Debug information */}
|
||||
<div className="text-sm mb-2">
|
||||
<div>User logged in: {user ? 'Yes' : 'No'}</div>
|
||||
<div>Resources loading: {resourcesLoading ? 'Yes' : 'No'}</div>
|
||||
<div>Available resources: {availableResources.length}</div>
|
||||
<div>Selected resources: {selectedResources.length}</div>
|
||||
</div>
|
||||
|
||||
{/* Resource selection */}
|
||||
<div className="flex flex-wrap gap-2 mb-2 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="w-full text-lg font-semibold mb-2">
|
||||
Study Resources
|
||||
</div>
|
||||
|
||||
{resourcesLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading resources...</div>
|
||||
) : resourceError ? (
|
||||
<div className="text-sm text-red-500">{resourceError}</div>
|
||||
) : availableResources.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No study resources found. Please add some resources in the Resources section first.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-full mb-2 text-sm">
|
||||
<span className="font-medium">Instructions:</span> Click on resources below to include them in your question.
|
||||
{selectedResources.length > 0 && (
|
||||
<div className="mt-1 text-blue-600 dark:text-blue-400">
|
||||
Selected {selectedResources.length} resource(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableResources.map(resource => {
|
||||
const isSelected = selectedResources.includes(resource.id);
|
||||
return (
|
||||
<button
|
||||
key={resource.id}
|
||||
onClick={() => handleResourceToggle(resource.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm transition-colors duration-200 flex items-center gap-2 ${
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:border-blue-500'
|
||||
}`}
|
||||
>
|
||||
{/* Icon for resource type */}
|
||||
<span className="text-lg">
|
||||
{resource.resource_type === 'file' ? '📄' : '🔗'}
|
||||
</span>
|
||||
{/* Resource name */}
|
||||
<span>
|
||||
{resource.title || resource.file_path?.split('/').pop() || 'Untitled'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 border-t dark:border-gray-700">
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 p-2 rounded-lg border dark:border-gray-700 bg-white dark:bg-gray-800"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/contexts/ChatContext.tsx
Normal file
125
src/contexts/ChatContext.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { ChatService } from '../services/chat';
|
||||
import { ChatSession, Message, Resource } from '../types/chat';
|
||||
import { useUser } from '@supabase/auth-helpers-react';
|
||||
|
||||
interface ChatContextType {
|
||||
currentSession: ChatSession | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
sendMessage: (message: string, resourceIds?: string[]) => Promise<void>;
|
||||
createNewSession: (title: string) => Promise<void>;
|
||||
loadSession: (sessionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
|
||||
export function ChatProvider({ children }: { children: React.ReactNode }) {
|
||||
const [currentSession, setCurrentSession] = useState<ChatSession | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const user = useUser();
|
||||
|
||||
const sendMessage = useCallback(async (message: string, resourceIds?: string[]) => {
|
||||
if (!currentSession) {
|
||||
throw new Error('No active chat session');
|
||||
}
|
||||
|
||||
console.log('ChatContext: Sending message with resourceIds:', resourceIds);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await ChatService.sendMessage(message, currentSession.id, resourceIds);
|
||||
console.log('ChatContext: Received response:', response);
|
||||
|
||||
setCurrentSession(prev => {
|
||||
if (!prev) return null;
|
||||
|
||||
const updatedSession = {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
createdAt: new Date().toISOString(),
|
||||
resourceIds
|
||||
},
|
||||
{
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response.message,
|
||||
createdAt: new Date().toISOString(),
|
||||
resourceIds
|
||||
}
|
||||
]
|
||||
};
|
||||
console.log('ChatContext: Updated session:', updatedSession);
|
||||
return updatedSession;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('ChatContext: Error sending message:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to send message');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentSession]);
|
||||
|
||||
const createNewSession = useCallback(async (title: string) => {
|
||||
if (!user) {
|
||||
setError('You must be logged in to start a chat');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const sessionId = await ChatService.createSession(title, user.id);
|
||||
const session = await ChatService.getSession(sessionId);
|
||||
setCurrentSession(session);
|
||||
} catch (err) {
|
||||
console.error('Error creating chat session:', err);
|
||||
setError('Failed to create chat session');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadSession = useCallback(async (sessionId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const session = await ChatService.getSession(sessionId);
|
||||
setCurrentSession(session);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load session');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{
|
||||
currentSession,
|
||||
loading,
|
||||
error,
|
||||
sendMessage,
|
||||
createNewSession,
|
||||
loadSession
|
||||
}}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChat() {
|
||||
const context = useContext(ChatContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useChat must be used within a ChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,5 +1,103 @@
|
|||
import { supabase } from './supabase';
|
||||
import type { ChatInstance, ChatMessage } from '../types/supabase';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Error types
|
||||
class ChatError extends Error {
|
||||
status?: number;
|
||||
code?: string;
|
||||
|
||||
constructor(message: string, status?: number, code?: string) {
|
||||
super(message);
|
||||
this.name = 'ChatError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
static fromOpenAIError(error: OpenAI.APIError): ChatError {
|
||||
return new ChatError(
|
||||
error.message,
|
||||
error.status,
|
||||
error.code
|
||||
);
|
||||
}
|
||||
|
||||
static fromError(error: unknown): ChatError {
|
||||
if (error instanceof ChatError) {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
return ChatError.fromOpenAIError(error);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return new ChatError(error.message);
|
||||
}
|
||||
return new ChatError('Unknown error occurred');
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting configuration
|
||||
const RATE_LIMIT = {
|
||||
MAX_REQUESTS: 5, // Increased to 5 requests per minute
|
||||
WINDOW_MS: 60000, // 1 minute window
|
||||
MIN_DELAY: 1000, // Reduced to 1 second between requests
|
||||
};
|
||||
|
||||
// Rate limiting state
|
||||
let requestTimestamps: number[] = [];
|
||||
let lastRequestTime = 0;
|
||||
|
||||
// Helper function to check and enforce rate limits
|
||||
const enforceRateLimit = async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up old timestamps
|
||||
requestTimestamps = requestTimestamps.filter(
|
||||
time => now - time < RATE_LIMIT.WINDOW_MS
|
||||
);
|
||||
|
||||
// Check if we've hit the rate limit
|
||||
if (requestTimestamps.length >= RATE_LIMIT.MAX_REQUESTS) {
|
||||
const oldestRequest = requestTimestamps[0];
|
||||
const timeToWait = RATE_LIMIT.WINDOW_MS - (now - oldestRequest);
|
||||
throw new ChatError(
|
||||
`Please wait ${Math.ceil(timeToWait / 1000)} seconds before sending another message.`,
|
||||
429,
|
||||
'rate_limit_exceeded'
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce minimum delay between requests with exponential backoff
|
||||
const timeSinceLastRequest = now - lastRequestTime;
|
||||
const requiredDelay = Math.min(
|
||||
RATE_LIMIT.MIN_DELAY * Math.pow(1.5, requestTimestamps.length),
|
||||
5000 // Cap at 5 seconds
|
||||
);
|
||||
|
||||
if (timeSinceLastRequest < requiredDelay) {
|
||||
await new Promise(resolve => setTimeout(resolve, requiredDelay - timeSinceLastRequest));
|
||||
}
|
||||
|
||||
// Update state
|
||||
requestTimestamps.push(now);
|
||||
lastRequestTime = now;
|
||||
};
|
||||
|
||||
// Initialize OpenAI with error checking
|
||||
const apiKey = import.meta.env.VITE_OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenAI API key is not configured in environment variables');
|
||||
}
|
||||
|
||||
// Verify API key format
|
||||
if (!apiKey.startsWith('sk-')) {
|
||||
throw new Error('Invalid OpenAI API key format. Key should start with "sk-"');
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey,
|
||||
dangerouslyAllowBrowser: true
|
||||
});
|
||||
|
||||
export const chatService = {
|
||||
async createChatInstance(userId: string, title: string): Promise<ChatInstance | null> {
|
||||
|
|
@ -86,58 +184,195 @@ export const chatService = {
|
|||
role: 'user' | 'assistant',
|
||||
metadata?: ChatMessage['metadata']
|
||||
): Promise<ChatMessage | null> {
|
||||
const { data: message, error: messageError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
chat_id: chatId,
|
||||
content,
|
||||
role,
|
||||
metadata,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
try {
|
||||
// Save the initial message
|
||||
const { data: message, error: messageError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
chat_id: chatId,
|
||||
content,
|
||||
role,
|
||||
metadata,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (messageError) {
|
||||
console.error('Error adding message:', messageError);
|
||||
return null;
|
||||
if (messageError) {
|
||||
console.error('Error adding message:', messageError);
|
||||
return null;
|
||||
}
|
||||
|
||||
// If this is a user message, generate and save AI response
|
||||
if (role === 'user') {
|
||||
try {
|
||||
// Enforce rate limit before making API call
|
||||
await enforceRateLimit();
|
||||
|
||||
// Get chat history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select()
|
||||
.eq('chat_id', chatId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
// If resources are selected, fetch their content
|
||||
let resourceContext = '';
|
||||
if (metadata?.selectedResources?.length > 0) {
|
||||
const { data: resources } = await supabase
|
||||
.from('study_resources')
|
||||
.select('*')
|
||||
.in('id', metadata.selectedResources);
|
||||
|
||||
if (resources && resources.length > 0) {
|
||||
// Build context from resources
|
||||
resourceContext = 'Here are the relevant study resources:\n\n';
|
||||
|
||||
for (const resource of resources) {
|
||||
resourceContext += `[${resource.title || resource.file_path}]\n`;
|
||||
|
||||
if (resource.resource_type === 'file' && resource.file_path) {
|
||||
try {
|
||||
// Download file content
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('study-materials')
|
||||
.download(resource.file_path);
|
||||
|
||||
if (downloadError) throw downloadError;
|
||||
|
||||
// Convert file content to text
|
||||
if (fileData) {
|
||||
if (resource.file_type?.includes('text') || resource.file_path.endsWith('.txt')) {
|
||||
const content = await fileData.text();
|
||||
resourceContext += content + '\n\n';
|
||||
} else if (resource.file_type?.includes('pdf')) {
|
||||
resourceContext += 'PDF content extraction not implemented yet\n\n';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading content for resource ${resource.id}:`, error);
|
||||
resourceContext += `Error loading content: ${error.message}\n\n`;
|
||||
}
|
||||
} else if (resource.resource_type === 'link') {
|
||||
resourceContext += `URL: ${resource.url}\n\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create completion with OpenAI
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4-turbo-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are an advanced AI study assistant powered by GPT-4 Turbo. You have enhanced capabilities including understanding complex topics, providing detailed explanations, and analyzing various types of content. You can process and discuss text documents, code, and structured data. Use your capabilities to provide comprehensive assistance while studying."
|
||||
},
|
||||
// Add resource context if available
|
||||
...(resourceContext ? [{
|
||||
role: "system",
|
||||
content: resourceContext
|
||||
}] : []),
|
||||
...(history?.map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content
|
||||
})) || []),
|
||||
{ role: "user", content }
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 4096,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0
|
||||
});
|
||||
|
||||
const aiResponse = completion.choices[0].message.content || '';
|
||||
|
||||
// Store the assistant's response
|
||||
const { data: message, error: insertError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
chat_id: chatId,
|
||||
content: aiResponse,
|
||||
role: 'assistant',
|
||||
metadata: { model: 'gpt-4-turbo-preview' }
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
throw new ChatError('Failed to save AI response', 500, 'database_error');
|
||||
}
|
||||
|
||||
// Update chat's last message timestamp
|
||||
await supabase
|
||||
.from('chat_instances')
|
||||
.update({ last_message_at: new Date().toISOString() })
|
||||
.eq('id', chatId);
|
||||
|
||||
return message;
|
||||
} catch (error) {
|
||||
const chatError = ChatError.fromError(error);
|
||||
|
||||
// Handle rate limit and quota errors gracefully
|
||||
let fallbackMessage = "The system is temporarily busy. Please try again in a few seconds.";
|
||||
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
if (error.status === 429) {
|
||||
if (error.message.includes('quota')) {
|
||||
fallbackMessage = "The API quota has been exceeded. Please try again in a few minutes.";
|
||||
} else {
|
||||
fallbackMessage = `The system is processing too many requests. Please wait 10-15 seconds before trying again.`;
|
||||
}
|
||||
} else if (error.status === 500) {
|
||||
fallbackMessage = "There was a temporary server error. Please try again.";
|
||||
} else if (error.status === 503) {
|
||||
fallbackMessage = "The service is temporarily unavailable. Please try again in a moment.";
|
||||
}
|
||||
}
|
||||
|
||||
// Save fallback response with improved metadata
|
||||
const { data: fallbackResponse } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
chat_id: chatId,
|
||||
content: fallbackMessage,
|
||||
role: 'assistant',
|
||||
metadata: {
|
||||
fallback: true,
|
||||
error: chatError.message,
|
||||
errorCode: chatError.code,
|
||||
errorStatus: chatError.status,
|
||||
retryAfter: error instanceof OpenAI.APIError ? error.headers?.['retry-after'] : undefined
|
||||
}
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return fallbackResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
} catch (error) {
|
||||
const chatError = error instanceof ChatError ? error : new ChatError(
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
console.error('Error in addMessage:', chatError);
|
||||
throw chatError;
|
||||
}
|
||||
|
||||
// Update last_message_at in chat instance
|
||||
const { error: updateError } = await supabase
|
||||
.from('chat_instances')
|
||||
.update({ last_message_at: new Date().toISOString() })
|
||||
.eq('id', chatId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating chat instance:', updateError);
|
||||
}
|
||||
|
||||
return message;
|
||||
},
|
||||
|
||||
async deleteChatInstance(chatId: string): Promise<boolean> {
|
||||
// Delete all messages first
|
||||
const { error: messagesError } = await supabase
|
||||
.from('chat_messages')
|
||||
.delete()
|
||||
.eq('chat_id', chatId);
|
||||
|
||||
if (messagesError) {
|
||||
console.error('Error deleting chat messages:', messagesError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then delete the chat instance
|
||||
const { error: instanceError } = await supabase
|
||||
const { error } = await supabase
|
||||
.from('chat_instances')
|
||||
.delete()
|
||||
.eq('id', chatId);
|
||||
|
||||
if (instanceError) {
|
||||
console.error('Error deleting chat instance:', instanceError);
|
||||
if (error) {
|
||||
console.error('Error deleting chat instance:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Send, Loader2, X, History, MessageSquarePlus, AlertCircle } from 'lucide-react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSupabaseClient } from '@supabase/auth-helpers-react';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { chatService } from '../../lib/chat-service';
|
||||
import type { ChatMessage, ChatInstance } from '../../types/supabase';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
export default function AskQuestion() {
|
||||
const { chatId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const supabase = useSupabaseClient();
|
||||
const [question, setQuestion] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
|
|
@ -18,8 +24,10 @@ export default function AskQuestion() {
|
|||
const [chat, setChat] = useState<ChatInstance | null>(null);
|
||||
const [isNewChat, setIsNewChat] = useState(false);
|
||||
const [hasExistingChats, setHasExistingChats] = useState<boolean | null>(null);
|
||||
const [selectedResources, setSelectedResources] = useState<string[]>([]);
|
||||
const [availableResources, setAvailableResources] = useState<any[]>([]);
|
||||
const [resourcesLoading, setResourcesLoading] = useState(true);
|
||||
|
||||
// Check for existing chats and redirect to most recent if on base path
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
|
|
@ -29,14 +37,11 @@ export default function AskQuestion() {
|
|||
const hasChats = chats.length > 0;
|
||||
setHasExistingChats(hasChats);
|
||||
|
||||
// If we're on the base ask page and there are existing chats,
|
||||
// redirect to the most recent one
|
||||
if (hasChats && !chatId && !isNewChat) {
|
||||
const mostRecentChat = chats[0]; // Chats are already sorted by last_message_at desc
|
||||
const mostRecentChat = chats[0];
|
||||
navigate(`/dashboard/ask/${mostRecentChat.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking existing chats:', err);
|
||||
setHasExistingChats(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -49,12 +54,10 @@ export default function AskQuestion() {
|
|||
|
||||
const loadChat = async () => {
|
||||
try {
|
||||
// Don't try to load chat if we're using a temporary ID
|
||||
if (chatId.startsWith('temp-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load chat instance
|
||||
const chatInstance = await chatService.getChatInstance(chatId);
|
||||
if (!chatInstance) {
|
||||
setError('Chat not found');
|
||||
|
|
@ -62,33 +65,92 @@ export default function AskQuestion() {
|
|||
}
|
||||
setChat(chatInstance);
|
||||
|
||||
// Load messages
|
||||
const messages = await chatService.getChatMessages(chatId);
|
||||
setMessages(messages);
|
||||
} catch (err) {
|
||||
setError('Failed to load chat');
|
||||
console.error('Error loading chat:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadChat();
|
||||
}, [chatId, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadResources = async () => {
|
||||
try {
|
||||
setResourcesLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { data: resources, error: dbError } = await supabase
|
||||
.from('study_resources')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (dbError) {
|
||||
throw dbError;
|
||||
}
|
||||
|
||||
const resourcesWithContent = await Promise.all((resources || []).map(async (resource) => {
|
||||
if (resource.resource_type === 'file' && resource.file_path) {
|
||||
try {
|
||||
const { data, error: storageError } = await supabase.storage
|
||||
.from('study-materials')
|
||||
.download(resource.file_path);
|
||||
|
||||
if (storageError) {
|
||||
throw storageError;
|
||||
}
|
||||
|
||||
let content = '';
|
||||
if (data) {
|
||||
if (resource.file_type?.includes('text') || resource.file_path.endsWith('.txt')) {
|
||||
content = await data.text();
|
||||
} else if (resource.file_type?.includes('pdf')) {
|
||||
content = 'PDF content extraction not implemented yet';
|
||||
}
|
||||
}
|
||||
|
||||
return { ...resource, content };
|
||||
} catch (error) {
|
||||
return { ...resource, content: `Error loading content: ${error.message}` };
|
||||
}
|
||||
}
|
||||
return { ...resource, content: resource.url };
|
||||
}));
|
||||
|
||||
setAvailableResources(resourcesWithContent);
|
||||
} catch (err) {
|
||||
setError('Failed to load study resources');
|
||||
} finally {
|
||||
setResourcesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadResources();
|
||||
}, [user, supabase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resourcesLoading && availableResources.length > 0) {
|
||||
setSelectedResources(availableResources.map(r => r.id));
|
||||
}
|
||||
}, [resourcesLoading, availableResources]);
|
||||
|
||||
const handleNewChat = () => {
|
||||
setIsNewChat(true);
|
||||
setChat(null);
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
// Generate a temporary ID for the new chat
|
||||
const tempId = 'temp-' + Date.now();
|
||||
navigate(`/dashboard/ask/${tempId}`);
|
||||
};
|
||||
|
||||
const generateChatTitle = (message: string) => {
|
||||
// Split into words and take first 5
|
||||
const words = message.split(/\s+/).slice(0, 5);
|
||||
|
||||
// Join words and add ellipsis if we truncated
|
||||
const title = words.join(' ');
|
||||
return words.length < message.split(/\s+/).length ? `${title}...` : title;
|
||||
};
|
||||
|
|
@ -103,7 +165,6 @@ export default function AskQuestion() {
|
|||
try {
|
||||
let currentChatId: string;
|
||||
|
||||
// If this is a new chat, create it first with the title from the first message
|
||||
if (isNewChat) {
|
||||
const title = generateChatTitle(question.trim());
|
||||
const newChat = await chatService.createChatInstance(user.id, title);
|
||||
|
|
@ -113,7 +174,6 @@ export default function AskQuestion() {
|
|||
currentChatId = newChat.id;
|
||||
setChat(newChat);
|
||||
setIsNewChat(false);
|
||||
// Update URL with the real chat ID
|
||||
navigate(`/dashboard/ask/${newChat.id}`, { replace: true });
|
||||
} else if (!chatId) {
|
||||
throw new Error('No chat ID available');
|
||||
|
|
@ -121,28 +181,45 @@ export default function AskQuestion() {
|
|||
currentChatId = chatId;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
const userMessage = await chatService.addMessage(currentChatId, question.trim(), 'user');
|
||||
if (userMessage) {
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
}
|
||||
|
||||
const userMessageContent = question.trim();
|
||||
const tempMessage: ChatMessage = {
|
||||
id: 'temp-' + Date.now(),
|
||||
chat_id: currentChatId,
|
||||
role: 'user',
|
||||
content: userMessageContent,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, tempMessage]);
|
||||
setQuestion('');
|
||||
|
||||
// TODO: Integrate with Make.com for AI response
|
||||
// For now, using a placeholder response
|
||||
const aiMessage = await chatService.addMessage(
|
||||
currentChatId,
|
||||
'This is a placeholder response. AI integration coming soon!',
|
||||
'assistant',
|
||||
{ make_response_id: 'placeholder' }
|
||||
);
|
||||
if (aiMessage) {
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
try {
|
||||
const userMessage = await chatService.addMessage(
|
||||
currentChatId,
|
||||
userMessageContent,
|
||||
'user',
|
||||
{ selectedResources }
|
||||
);
|
||||
if (!userMessage) {
|
||||
throw new Error('Failed to send message');
|
||||
}
|
||||
|
||||
const updatedMessages = await chatService.getChatMessages(currentChatId);
|
||||
setMessages(updatedMessages);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (err.message?.includes('Rate limit exceeded')) {
|
||||
setError('Too many requests. Please wait a moment before trying again.');
|
||||
} else if (err.message?.includes('API key')) {
|
||||
setError('AI service configuration error. Please contact support.');
|
||||
} else {
|
||||
setError(err.message || 'Failed to process message');
|
||||
}
|
||||
|
||||
setMessages(prev => prev.filter(m => !m.id.startsWith('temp-')));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to send message');
|
||||
console.error('Failed to process message:', err);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
setError(err.message || 'Failed to send message');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -155,7 +232,6 @@ export default function AskQuestion() {
|
|||
setError(null);
|
||||
const success = await chatService.deleteChatInstance(chatId);
|
||||
if (success) {
|
||||
// After deleting, check if there are other chats to navigate to
|
||||
const remainingChats = await chatService.getChatInstances(user!.id);
|
||||
if (remainingChats.length > 0) {
|
||||
navigate(`/dashboard/ask/${remainingChats[0].id}`);
|
||||
|
|
@ -165,11 +241,9 @@ export default function AskQuestion() {
|
|||
}
|
||||
} catch (err) {
|
||||
setError('Failed to clear chat');
|
||||
console.error('Error clearing chat:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state while checking for existing chats
|
||||
if (hasExistingChats === null) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -179,16 +253,9 @@ export default function AskQuestion() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Ask a Question</h1>
|
||||
{chat && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{chat.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-center p-4">
|
||||
<h1 className="text-3xl font-bold">Ask a Question</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleNewChat}>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
|
|
@ -218,6 +285,67 @@ export default function AskQuestion() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<div className="p-3 flex justify-between items-center border-b">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Study Resources ({availableResources.length})
|
||||
</div>
|
||||
{!resourcesLoading && availableResources.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedResources(prev =>
|
||||
prev.length === availableResources.length ? [] : availableResources.map(r => r.id)
|
||||
)}
|
||||
className="text-xs hover:underline underline-offset-4"
|
||||
>
|
||||
{selectedResources.length === availableResources.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{resourcesLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : availableResources.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/upload" className="hover:underline underline-offset-4">
|
||||
Add some study resources
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{availableResources.map(resource => (
|
||||
<button
|
||||
key={resource.id}
|
||||
onClick={() => {
|
||||
setSelectedResources(prev =>
|
||||
prev.includes(resource.id)
|
||||
? prev.filter(id => id !== resource.id)
|
||||
: [...prev, resource.id]
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
selectedResources.includes(resource.id)
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90 dark:bg-primary dark:text-primary-foreground dark:hover:bg-primary/90"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80 dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 opacity-70">
|
||||
{resource.resource_type === 'file' ? '📄' : '🔗'}
|
||||
</span>
|
||||
<span className="truncate max-w-[150px]">
|
||||
{resource.title || resource.file_path?.split('/').pop() || 'Untitled'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{!isNewChat && !chatId ? (
|
||||
<div className="flex h-full items-center justify-center text-center">
|
||||
|
|
@ -258,7 +386,54 @@ export default function AskQuestion() {
|
|||
: 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
{message.role === 'user' ? (
|
||||
message.content
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
className="prose prose-sm dark:prose-invert max-w-none"
|
||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-2">{children}</p>,
|
||||
ul: ({ children }) => <ul className="mb-2 list-disc pl-4">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="mb-2 list-decimal pl-4">{children}</ol>,
|
||||
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||
h1: ({ children }) => <h1 className="mb-2 text-xl font-bold">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-2 text-lg font-bold">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-2 text-base font-bold">{children}</h3>,
|
||||
code: ({ node, inline, className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline ? (
|
||||
<pre className="mb-2 rounded bg-gray-900 p-2 text-sm">
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
) : (
|
||||
<code className="rounded bg-gray-200 px-1 py-0.5 text-sm dark:bg-gray-800" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="mb-2 border-l-2 border-gray-300 pl-4 italic dark:border-gray-600">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
|
@ -272,7 +447,7 @@ export default function AskQuestion() {
|
|||
type="text"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="Type your question..."
|
||||
placeholder="Type your question... "
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export default function StudyHistory() {
|
|||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Study History</h1>
|
||||
<h1 className="text-3xl font-bold">Study History</h1>
|
||||
<p className="text-muted-foreground">View your past conversations</p>
|
||||
</div>
|
||||
<Link to="/dashboard/ask">
|
||||
|
|
|
|||
260
src/services/chat.ts
Normal file
260
src/services/chat.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { supabase } from '../lib/supabase';
|
||||
import { ChatResponse, Resource } from '../types/chat';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY;
|
||||
|
||||
if (!OPENAI_API_KEY) {
|
||||
throw new Error(
|
||||
'OpenAI API key is missing. Please add VITE_OPENAI_API_KEY to your .env file'
|
||||
);
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: OPENAI_API_KEY,
|
||||
dangerouslyAllowBrowser: true
|
||||
});
|
||||
|
||||
export class ChatService {
|
||||
private static async getResourceContext(resourceIds: string[]): Promise<string> {
|
||||
console.log('Fetching resources with IDs:', resourceIds);
|
||||
|
||||
const { data: resources, error } = await supabase
|
||||
.from('study_resources')
|
||||
.select('*')
|
||||
.in('id', resourceIds);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching resources:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
console.log('No resources found with the provided IDs');
|
||||
return '';
|
||||
}
|
||||
|
||||
console.log('Found resources:', resources);
|
||||
|
||||
// Process each resource to get its content
|
||||
const processedResources = await Promise.all(
|
||||
resources.map(async (resource) => {
|
||||
console.log('Processing resource:', resource);
|
||||
|
||||
if (resource.resource_type === 'file' && resource.file_path) {
|
||||
try {
|
||||
console.log('Downloading file from path:', resource.file_path);
|
||||
|
||||
// Download file content from storage
|
||||
const { data, error: downloadError } = await supabase.storage
|
||||
.from('study-materials')
|
||||
.download(resource.file_path);
|
||||
|
||||
if (downloadError) {
|
||||
console.error('Error downloading file:', downloadError);
|
||||
throw downloadError;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
console.error('No data received from storage');
|
||||
throw new Error('No data received from storage');
|
||||
}
|
||||
|
||||
// Convert file content to text
|
||||
const text = await data.text();
|
||||
console.log('Successfully read file content, length:', text.length);
|
||||
|
||||
// Extract file extension for context
|
||||
const fileExt = resource.file_path.split('.').pop()?.toLowerCase();
|
||||
let fileType = 'document';
|
||||
if (fileExt === 'pdf') fileType = 'PDF document';
|
||||
else if (['doc', 'docx'].includes(fileExt || '')) fileType = 'Word document';
|
||||
else if (['ppt', 'pptx'].includes(fileExt || '')) fileType = 'PowerPoint presentation';
|
||||
else if (['txt', 'md'].includes(fileExt || '')) fileType = 'text document';
|
||||
|
||||
// Store the content in Supabase for caching
|
||||
await supabase
|
||||
.from('study_resources')
|
||||
.update({ content: text })
|
||||
.eq('id', resource.id);
|
||||
|
||||
return `[${resource.title || resource.file_path}] (${fileType})
|
||||
Content:
|
||||
${text}
|
||||
---`;
|
||||
} catch (err) {
|
||||
console.error(`Error processing file resource ${resource.id}:`, err);
|
||||
// Try to get cached content if available
|
||||
if (resource.content) {
|
||||
console.log('Using cached content for resource:', resource.id);
|
||||
return `[${resource.title || resource.file_path}] (cached)
|
||||
Content:
|
||||
${resource.content}
|
||||
---`;
|
||||
}
|
||||
return `[${resource.title || resource.file_path}]
|
||||
Error: Could not load file content. Error: ${err instanceof Error ? err.message : 'Unknown error'}
|
||||
---`;
|
||||
}
|
||||
} else if (resource.resource_type === 'link' && resource.url) {
|
||||
return `[${resource.title || 'Link'}]
|
||||
Type: Web Resource
|
||||
URL: ${resource.url}
|
||||
---`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
);
|
||||
|
||||
const finalContext = processedResources.join('\n\n');
|
||||
console.log('Final context length:', finalContext.length);
|
||||
return finalContext;
|
||||
}
|
||||
|
||||
private static async getAllResources(userId: string): Promise<Resource[]> {
|
||||
const { data: resources, error } = await supabase
|
||||
.from('study_resources')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return resources;
|
||||
}
|
||||
|
||||
static async sendMessage(message: string, sessionId: string, resourceIds?: string[]): Promise<ChatResponse> {
|
||||
console.log('Sending message with resourceIds:', resourceIds);
|
||||
|
||||
try {
|
||||
// Get user ID from session
|
||||
const { data: session, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('user_id')
|
||||
.eq('id', sessionId)
|
||||
.single();
|
||||
|
||||
if (sessionError) {
|
||||
console.error('Error fetching session:', sessionError);
|
||||
throw sessionError;
|
||||
}
|
||||
|
||||
if (!session?.user_id) {
|
||||
console.error('No user_id found in session');
|
||||
throw new Error('No user_id associated with this chat session');
|
||||
}
|
||||
|
||||
// Get context from resources if provided
|
||||
let context = '';
|
||||
if (resourceIds && resourceIds.length > 0) {
|
||||
context = await this.getResourceContext(resourceIds);
|
||||
}
|
||||
|
||||
console.log('Retrieved context length:', context.length);
|
||||
|
||||
// Construct the system message with enhanced instructions
|
||||
const systemMessage = `You are a knowledgeable AI assistant with access to the user's study materials. Here are the available documents:
|
||||
|
||||
${context}
|
||||
|
||||
Instructions for handling study materials:
|
||||
1. Analyze and use the provided documents to answer questions accurately
|
||||
2. If a question cannot be answered using the available documents, clearly state this
|
||||
3. When referencing information, cite the specific document it came from using its title in square brackets
|
||||
4. For file content:
|
||||
- Understand and interpret the content based on the file type
|
||||
- Consider the context and format of different document types (PDF, Word, PowerPoint, etc.)
|
||||
5. For web resources:
|
||||
- Reference the URL when relevant
|
||||
- Mention if the information comes from a web resource
|
||||
6. Keep responses focused and educational
|
||||
7. If appropriate, suggest how other available resources might be relevant to the topic`;
|
||||
|
||||
// Get chat history
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.eq('sessionId', sessionId)
|
||||
.order('createdAt', { ascending: true });
|
||||
|
||||
// Create completion with OpenAI
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4",
|
||||
messages: [
|
||||
{ role: "system", content: systemMessage },
|
||||
...(messages?.map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content
|
||||
})) || []),
|
||||
{ role: "user", content: message }
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 1500
|
||||
});
|
||||
|
||||
const response = completion.choices[0].message.content || '';
|
||||
|
||||
// Save messages to Supabase
|
||||
await supabase.from('messages').insert([
|
||||
{
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: message,
|
||||
resourceIds
|
||||
},
|
||||
{
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
resourceIds
|
||||
}
|
||||
]);
|
||||
|
||||
// Find related resources based on the response
|
||||
const allResources = session?.user_id
|
||||
? await this.getAllResources(session.user_id)
|
||||
: [];
|
||||
|
||||
const relatedResources = allResources.filter(resource =>
|
||||
response.toLowerCase().includes((resource.title || '').toLowerCase()) ||
|
||||
(resourceIds && resourceIds.includes(resource.id))
|
||||
);
|
||||
|
||||
return {
|
||||
message: response,
|
||||
relatedResources
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in chat service:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createSession(title: string, userId: string): Promise<string> {
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert([{
|
||||
title,
|
||||
user_id: userId,
|
||||
created_at: new Date().toISOString()
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data.id;
|
||||
}
|
||||
|
||||
static async getSession(sessionId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select(`
|
||||
*,
|
||||
messages:messages(*)
|
||||
`)
|
||||
.eq('id', sessionId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
32
src/types/chat.ts
Normal file
32
src/types/chat.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
resourceIds?: string[]; // References to resources mentioned in the message
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: Message[];
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
title: string;
|
||||
resource_type: 'file' | 'link';
|
||||
file_path?: string;
|
||||
url?: string;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
message: string;
|
||||
relatedResources?: Resource[];
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ export interface ChatInstance {
|
|||
created_at: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
last_message_at: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
|
@ -13,8 +12,28 @@ export interface ChatMessage {
|
|||
role: 'user' | 'assistant';
|
||||
created_at: string;
|
||||
metadata?: {
|
||||
make_response_id?: string;
|
||||
error?: string;
|
||||
model?: string;
|
||||
type?: 'text' | 'image' | 'pdf' | 'link';
|
||||
url?: string;
|
||||
fileType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
url?: string;
|
||||
file_path?: string;
|
||||
resource_type: 'file' | 'link' | 'text';
|
||||
metadata?: {
|
||||
fileType?: string;
|
||||
fileName?: string;
|
||||
size?: number;
|
||||
lastModified?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
import typography from '@tailwindcss/typography';
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
|
|
@ -57,7 +59,19 @@ export default {
|
|||
md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
typography: {
|
||||
DEFAULT: {
|
||||
css: {
|
||||
maxWidth: 'none',
|
||||
color: 'inherit',
|
||||
pre: {
|
||||
backgroundColor: 'rgb(17, 24, 39)',
|
||||
color: 'rgb(229, 231, 235)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
plugins: [typography],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue