Yassine Mhirsi commited on
Commit ·
ee3eb53
1
Parent(s): c01020f
argument generation
Browse files- src/app/components/chat/ChatInput.tsx +24 -4
- src/app/components/chat/MessageList.tsx +115 -0
- src/app/hooks/index.ts +1 -0
- src/app/hooks/useChat.ts +160 -0
- src/app/pages/ChatPage.tsx +36 -7
- src/app/services/argument.service.ts +45 -0
- src/app/services/groq.service.ts +216 -0
- src/app/types/chat.types.ts +20 -0
- src/app/types/index.ts +1 -0
src/app/components/chat/ChatInput.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { useMCPTools } from '../../hooks/useMCPTools.ts';
|
|
| 4 |
import type { MCPTool } from '../../types/index.ts';
|
| 5 |
|
| 6 |
type ChatInputProps = {
|
| 7 |
-
onSubmit?: (message: string) => void;
|
| 8 |
placeholder?: string;
|
| 9 |
};
|
| 10 |
|
|
@@ -31,7 +31,7 @@ const ChatInput = ({ onSubmit, placeholder = 'Ask a follow-up...' }: ChatInputPr
|
|
| 31 |
e.preventDefault();
|
| 32 |
if (input.trim()) {
|
| 33 |
if (onSubmit) {
|
| 34 |
-
onSubmit(input);
|
| 35 |
}
|
| 36 |
console.log('Submitted:', input);
|
| 37 |
setInput('');
|
|
@@ -253,6 +253,17 @@ const ChatInput = ({ onSubmit, placeholder = 'Ask a follow-up...' }: ChatInputPr
|
|
| 253 |
<textarea
|
| 254 |
value={input}
|
| 255 |
onChange={(e) => setInput(e.target.value)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
placeholder={placeholder}
|
| 257 |
className="w-full bg-transparent text-zinc-800 dark:text-gray-300 placeholder-zinc-400 dark:placeholder-gray-500 resize-none border-none outline-none text-base leading-relaxed min-h-[24px] max-h-32 transition-all duration-200"
|
| 258 |
rows={1}
|
|
@@ -442,9 +453,18 @@ const ChatInput = ({ onSubmit, placeholder = 'Ask a follow-up...' }: ChatInputPr
|
|
| 442 |
|
| 443 |
<button
|
| 444 |
type="button"
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
>
|
| 447 |
-
|
| 448 |
</button>
|
| 449 |
</div>
|
| 450 |
|
|
|
|
| 4 |
import type { MCPTool } from '../../types/index.ts';
|
| 5 |
|
| 6 |
type ChatInputProps = {
|
| 7 |
+
onSubmit?: (message: string, selectedTool?: string | null) => void;
|
| 8 |
placeholder?: string;
|
| 9 |
};
|
| 10 |
|
|
|
|
| 31 |
e.preventDefault();
|
| 32 |
if (input.trim()) {
|
| 33 |
if (onSubmit) {
|
| 34 |
+
onSubmit(input, selectedTool);
|
| 35 |
}
|
| 36 |
console.log('Submitted:', input);
|
| 37 |
setInput('');
|
|
|
|
| 253 |
<textarea
|
| 254 |
value={input}
|
| 255 |
onChange={(e) => setInput(e.target.value)}
|
| 256 |
+
onKeyDown={(e) => {
|
| 257 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 258 |
+
e.preventDefault();
|
| 259 |
+
if (input.trim()) {
|
| 260 |
+
if (onSubmit) {
|
| 261 |
+
onSubmit(input, selectedTool);
|
| 262 |
+
}
|
| 263 |
+
setInput('');
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
}}
|
| 267 |
placeholder={placeholder}
|
| 268 |
className="w-full bg-transparent text-zinc-800 dark:text-gray-300 placeholder-zinc-400 dark:placeholder-gray-500 resize-none border-none outline-none text-base leading-relaxed min-h-[24px] max-h-32 transition-all duration-200"
|
| 269 |
rows={1}
|
|
|
|
| 453 |
|
| 454 |
<button
|
| 455 |
type="button"
|
| 456 |
+
onClick={() => {
|
| 457 |
+
if (input.trim()) {
|
| 458 |
+
if (onSubmit) {
|
| 459 |
+
onSubmit(input, selectedTool);
|
| 460 |
+
}
|
| 461 |
+
setInput('');
|
| 462 |
+
}
|
| 463 |
+
}}
|
| 464 |
+
disabled={!input.trim()}
|
| 465 |
+
className="h-8 px-3 rounded-lg text-sm font-medium hover:opacity-90 transition-all duration-200 hover:scale-105 flex items-center justify-center bg-teal-900 dark:bg-[#032827] text-teal-300 dark:text-[#2DD4BF] disabled:opacity-50 disabled:cursor-not-allowed"
|
| 466 |
>
|
| 467 |
+
Send
|
| 468 |
</button>
|
| 469 |
</div>
|
| 470 |
|
src/app/components/chat/MessageList.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef } from 'react';
|
| 2 |
+
import type { ChatMessage } from '../../types/chat.types.ts';
|
| 3 |
+
import { Loader2, AlertCircle, RotateCcw } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
type MessageListProps = {
|
| 6 |
+
messages: ChatMessage[];
|
| 7 |
+
isLoading?: boolean;
|
| 8 |
+
error?: string | null;
|
| 9 |
+
onRetry?: () => void;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const MessageList = ({ messages, isLoading = false, error = null, onRetry }: MessageListProps) => {
|
| 13 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 14 |
+
|
| 15 |
+
const scrollToBottom = () => {
|
| 16 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
scrollToBottom();
|
| 21 |
+
}, [messages, isLoading]);
|
| 22 |
+
|
| 23 |
+
const formatTime = (date: Date) => {
|
| 24 |
+
return date.toLocaleTimeString('en-US', {
|
| 25 |
+
hour: '2-digit',
|
| 26 |
+
minute: '2-digit',
|
| 27 |
+
});
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
|
| 32 |
+
{messages.length === 0 && !isLoading && (
|
| 33 |
+
<div className="flex flex-col items-center justify-center h-full text-center">
|
| 34 |
+
<div className="mb-4">
|
| 35 |
+
<div className="w-16 h-16 bg-gradient-to-br from-teal-400 to-blue-500 rounded-full flex items-center justify-center">
|
| 36 |
+
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 37 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
| 38 |
+
</svg>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
| 42 |
+
Start a conversation
|
| 43 |
+
</h3>
|
| 44 |
+
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-md">
|
| 45 |
+
Ask me anything! I'm powered by Meta's Llama 4 Scout model and can help with a wide range of topics.
|
| 46 |
+
</p>
|
| 47 |
+
</div>
|
| 48 |
+
)}
|
| 49 |
+
|
| 50 |
+
{messages.map((message) => (
|
| 51 |
+
<div
|
| 52 |
+
key={message.id}
|
| 53 |
+
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
| 54 |
+
>
|
| 55 |
+
<div
|
| 56 |
+
className={`max-w-3xl px-4 py-3 rounded-2xl ${
|
| 57 |
+
message.role === 'user'
|
| 58 |
+
? 'bg-teal-500 text-white ml-12'
|
| 59 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 mr-12'
|
| 60 |
+
}`}
|
| 61 |
+
>
|
| 62 |
+
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
| 63 |
+
<div
|
| 64 |
+
className={`text-xs mt-1 ${
|
| 65 |
+
message.role === 'user'
|
| 66 |
+
? 'text-teal-100'
|
| 67 |
+
: 'text-gray-500 dark:text-gray-400'
|
| 68 |
+
}`}
|
| 69 |
+
>
|
| 70 |
+
{formatTime(message.timestamp)}
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
))}
|
| 75 |
+
|
| 76 |
+
{isLoading && (
|
| 77 |
+
<div className="flex justify-start">
|
| 78 |
+
<div className="bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 mr-12 max-w-3xl px-4 py-3 rounded-2xl">
|
| 79 |
+
<div className="flex items-center space-x-2">
|
| 80 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 81 |
+
<span className="text-sm">Thinking...</span>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
|
| 87 |
+
{error && (
|
| 88 |
+
<div className="flex justify-start">
|
| 89 |
+
<div className="bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100 mr-12 max-w-3xl px-4 py-3 rounded-2xl border border-red-200 dark:border-red-800">
|
| 90 |
+
<div className="flex items-start space-x-2">
|
| 91 |
+
<AlertCircle className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
| 92 |
+
<div className="flex-1">
|
| 93 |
+
<p className="text-sm font-medium">Error</p>
|
| 94 |
+
<p className="text-sm opacity-90">{error}</p>
|
| 95 |
+
{onRetry && (
|
| 96 |
+
<button
|
| 97 |
+
onClick={onRetry}
|
| 98 |
+
className="mt-2 flex items-center space-x-1 text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors"
|
| 99 |
+
>
|
| 100 |
+
<RotateCcw className="w-3 h-3" />
|
| 101 |
+
<span>Retry</span>
|
| 102 |
+
</button>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
)}
|
| 109 |
+
|
| 110 |
+
<div ref={messagesEndRef} />
|
| 111 |
+
</div>
|
| 112 |
+
);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
export default MessageList;
|
src/app/hooks/index.ts
CHANGED
|
@@ -6,4 +6,5 @@ export { default as useApi } from './useApi.ts';
|
|
| 6 |
export { useApi as useApiHook } from './useApi.ts';
|
| 7 |
export { useTheme } from './useTheme.ts';
|
| 8 |
export { useAuth } from './useAuth.ts';
|
|
|
|
| 9 |
|
|
|
|
| 6 |
export { useApi as useApiHook } from './useApi.ts';
|
| 7 |
export { useTheme } from './useTheme.ts';
|
| 8 |
export { useAuth } from './useAuth.ts';
|
| 9 |
+
export { useChat } from './useChat.ts';
|
| 10 |
|
src/app/hooks/useChat.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react';
|
| 2 |
+
import { sendChatMessageStream } from '../services/groq.service.ts';
|
| 3 |
+
import { generateEnhancedArgument } from '../services/argument.service.ts';
|
| 4 |
+
import type { ChatMessage, ChatState } from '../types/chat.types.ts';
|
| 5 |
+
|
| 6 |
+
const generateId = () => Math.random().toString(36).substring(2, 15);
|
| 7 |
+
|
| 8 |
+
const initialState: ChatState = {
|
| 9 |
+
messages: [],
|
| 10 |
+
isLoading: false,
|
| 11 |
+
error: null,
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export const useChat = () => {
|
| 15 |
+
const [state, setState] = useState<ChatState>(initialState);
|
| 16 |
+
|
| 17 |
+
const addMessage = useCallback((message: Omit<ChatMessage, 'id' | 'timestamp'>) => {
|
| 18 |
+
setState(prev => ({
|
| 19 |
+
...prev,
|
| 20 |
+
messages: [...prev.messages, {
|
| 21 |
+
...message,
|
| 22 |
+
id: generateId(),
|
| 23 |
+
timestamp: new Date(),
|
| 24 |
+
}],
|
| 25 |
+
}));
|
| 26 |
+
}, []);
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
const extractTopicAndPosition = useCallback(async (userInput: string): Promise<{ topic: string; position: string }> => {
|
| 30 |
+
const extractionPrompt = `Extract the debate topic and position from the following text. Return ONLY a JSON object with "topic" and "position" keys. The position should be either "positive" (in favor) or "negative" (against).
|
| 31 |
+
|
| 32 |
+
Text: "${userInput}"
|
| 33 |
+
|
| 34 |
+
Example output:
|
| 35 |
+
{"topic": "Assisted suicide should be a criminal offence", "position": "positive"}`;
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
const response = await sendChatMessageStream([
|
| 39 |
+
{ id: generateId(), role: 'user', content: extractionPrompt, timestamp: new Date() }
|
| 40 |
+
], () => {});
|
| 41 |
+
|
| 42 |
+
// Parse the JSON response
|
| 43 |
+
const jsonMatch = response.match(/\{[^}]+\}/);
|
| 44 |
+
if (jsonMatch) {
|
| 45 |
+
return JSON.parse(jsonMatch[0]);
|
| 46 |
+
}
|
| 47 |
+
throw new Error('Failed to extract topic and position');
|
| 48 |
+
} catch (error) {
|
| 49 |
+
throw new Error('Failed to extract topic and position from input');
|
| 50 |
+
}
|
| 51 |
+
}, []);
|
| 52 |
+
|
| 53 |
+
const sendMessage = useCallback(async (content: string, selectedTool?: string | null) => {
|
| 54 |
+
// Add user message
|
| 55 |
+
const userMessage: Omit<ChatMessage, 'id' | 'timestamp'> = {
|
| 56 |
+
role: 'user',
|
| 57 |
+
content,
|
| 58 |
+
};
|
| 59 |
+
addMessage(userMessage);
|
| 60 |
+
|
| 61 |
+
// Set loading state
|
| 62 |
+
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
| 63 |
+
|
| 64 |
+
try {
|
| 65 |
+
// Get all messages including the new user message
|
| 66 |
+
const updatedMessages = [...state.messages, { ...userMessage, id: generateId(), timestamp: new Date() }];
|
| 67 |
+
|
| 68 |
+
// Check if generate argument tool is selected (check for multiple possible names)
|
| 69 |
+
const isGenerateArgumentTool = selectedTool && (
|
| 70 |
+
selectedTool.toLowerCase().includes('generate') &&
|
| 71 |
+
selectedTool.toLowerCase().includes('argument')
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
if (isGenerateArgumentTool) {
|
| 75 |
+
console.log('Generate argument tool detected:', selectedTool);
|
| 76 |
+
// Extract topic and position using LLM
|
| 77 |
+
const { topic, position } = await extractTopicAndPosition(content);
|
| 78 |
+
|
| 79 |
+
// Call the external API
|
| 80 |
+
const argumentResponse = await generateEnhancedArgument({ topic, position });
|
| 81 |
+
|
| 82 |
+
// Add assistant response with the enhanced argument
|
| 83 |
+
const assistantMessage: Omit<ChatMessage, 'id' | 'timestamp'> = {
|
| 84 |
+
role: 'assistant',
|
| 85 |
+
content: argumentResponse.enhanced_argument,
|
| 86 |
+
};
|
| 87 |
+
addMessage(assistantMessage);
|
| 88 |
+
} else {
|
| 89 |
+
// Regular chat with streaming
|
| 90 |
+
// Create an empty assistant message that will be updated with streaming content
|
| 91 |
+
const assistantId = generateId();
|
| 92 |
+
const assistantMessage: ChatMessage = {
|
| 93 |
+
id: assistantId,
|
| 94 |
+
role: 'assistant',
|
| 95 |
+
content: '',
|
| 96 |
+
timestamp: new Date(),
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
setState(prev => ({
|
| 100 |
+
...prev,
|
| 101 |
+
messages: [...prev.messages, assistantMessage],
|
| 102 |
+
}));
|
| 103 |
+
|
| 104 |
+
// Send to Groq API with streaming
|
| 105 |
+
await sendChatMessageStream(updatedMessages, (chunk) => {
|
| 106 |
+
setState(prev => ({
|
| 107 |
+
...prev,
|
| 108 |
+
messages: prev.messages.map(msg =>
|
| 109 |
+
msg.id === assistantId ? { ...msg, content: msg.content + chunk } : msg
|
| 110 |
+
),
|
| 111 |
+
}));
|
| 112 |
+
});
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
setState(prev => ({ ...prev, isLoading: false }));
|
| 116 |
+
} catch (error) {
|
| 117 |
+
const errorMessage = error instanceof Error ? error.message : 'Failed to send message';
|
| 118 |
+
setState(prev => ({ ...prev, isLoading: false, error: errorMessage }));
|
| 119 |
+
}
|
| 120 |
+
}, [state.messages, addMessage, extractTopicAndPosition]);
|
| 121 |
+
|
| 122 |
+
const clearMessages = useCallback(() => {
|
| 123 |
+
setState(initialState);
|
| 124 |
+
}, []);
|
| 125 |
+
|
| 126 |
+
const retryLastMessage = useCallback(() => {
|
| 127 |
+
if (state.messages.length >= 2) {
|
| 128 |
+
const lastUserMessage = [...state.messages]
|
| 129 |
+
.reverse()
|
| 130 |
+
.find(msg => msg.role === 'user');
|
| 131 |
+
|
| 132 |
+
if (lastUserMessage) {
|
| 133 |
+
// Remove the last failed assistant message if exists
|
| 134 |
+
setState(prev => {
|
| 135 |
+
const lastMessage = prev.messages[prev.messages.length - 1];
|
| 136 |
+
if (lastMessage?.role === 'assistant') {
|
| 137 |
+
return {
|
| 138 |
+
...prev,
|
| 139 |
+
messages: prev.messages.slice(0, -1),
|
| 140 |
+
error: null,
|
| 141 |
+
};
|
| 142 |
+
}
|
| 143 |
+
return { ...prev, error: null };
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
// Resend the user message
|
| 147 |
+
sendMessage(lastUserMessage.content);
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
}, [state.messages, sendMessage]);
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
messages: state.messages,
|
| 154 |
+
isLoading: state.isLoading,
|
| 155 |
+
error: state.error,
|
| 156 |
+
sendMessage,
|
| 157 |
+
clearMessages,
|
| 158 |
+
retryLastMessage,
|
| 159 |
+
};
|
| 160 |
+
};
|
src/app/pages/ChatPage.tsx
CHANGED
|
@@ -1,17 +1,46 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
import ChatInput from '../components/chat/ChatInput.tsx';
|
|
|
|
|
|
|
| 3 |
|
| 4 |
const ChatPage = () => {
|
| 5 |
-
const
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
};
|
| 9 |
|
|
|
|
|
|
|
| 10 |
return (
|
| 11 |
-
<div className=
|
| 12 |
-
|
| 13 |
-
<
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
</div>
|
| 16 |
);
|
| 17 |
};
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import ChatInput from '../components/chat/ChatInput.tsx';
|
| 3 |
+
import MessageList from '../components/chat/MessageList.tsx';
|
| 4 |
+
import { useChat } from '../hooks/useChat.ts';
|
| 5 |
|
| 6 |
const ChatPage = () => {
|
| 7 |
+
const { messages, isLoading, error, sendMessage, retryLastMessage } = useChat();
|
| 8 |
+
|
| 9 |
+
const handleMessageSubmit = (message: string, selectedTool?: string | null) => {
|
| 10 |
+
sendMessage(message, selectedTool);
|
| 11 |
};
|
| 12 |
|
| 13 |
+
const hasConversation = messages.length > 0;
|
| 14 |
+
|
| 15 |
return (
|
| 16 |
+
<div className={`flex min-h-screen bg-white dark:bg-black transition-colors duration-200 ${hasConversation ? 'flex-col' : ''}`}>
|
| 17 |
+
{hasConversation ? (
|
| 18 |
+
<>
|
| 19 |
+
{/* Messages */}
|
| 20 |
+
<div className="flex-1 max-w-4xl w-full mx-auto pt-20">
|
| 21 |
+
<MessageList
|
| 22 |
+
messages={messages}
|
| 23 |
+
isLoading={isLoading}
|
| 24 |
+
error={error}
|
| 25 |
+
onRetry={retryLastMessage}
|
| 26 |
+
/>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
{/* Input */}
|
| 30 |
+
<div className="px-4 py-4">
|
| 31 |
+
<div className="max-w-4xl mx-auto">
|
| 32 |
+
<ChatInput onSubmit={handleMessageSubmit} placeholder="Type your message..." />
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
</>
|
| 36 |
+
) : (
|
| 37 |
+
/* Centered input when no conversation */
|
| 38 |
+
<div className="flex items-center justify-center px-4 pt-20 pb-10 w-full">
|
| 39 |
+
<div className="w-full max-w-4xl">
|
| 40 |
+
<ChatInput onSubmit={handleMessageSubmit} placeholder="Ask me anything..." />
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
)}
|
| 44 |
</div>
|
| 45 |
);
|
| 46 |
};
|
src/app/services/argument.service.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type ArgumentRequest = {
|
| 2 |
+
topic: string;
|
| 3 |
+
position: string;
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export type ArgumentResponse = {
|
| 7 |
+
topic: string;
|
| 8 |
+
position: string;
|
| 9 |
+
original_argument: string;
|
| 10 |
+
enhanced_argument: string;
|
| 11 |
+
process: string;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Calls the external N8N webhook to generate enhanced arguments
|
| 16 |
+
*/
|
| 17 |
+
export async function generateEnhancedArgument(request: ArgumentRequest): Promise<ArgumentResponse> {
|
| 18 |
+
const webhookUrl = process.env.REACT_APP_N8N_WEBHOOK_URL;
|
| 19 |
+
|
| 20 |
+
if (!webhookUrl) {
|
| 21 |
+
throw new Error('REACT_APP_N8N_WEBHOOK_URL environment variable is not set');
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const response = await fetch(`${webhookUrl}/generate-argument-enhanced`, {
|
| 26 |
+
method: 'POST',
|
| 27 |
+
headers: {
|
| 28 |
+
'Content-Type': 'application/json',
|
| 29 |
+
},
|
| 30 |
+
body: JSON.stringify(request),
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
if (!response.ok) {
|
| 34 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const data: ArgumentResponse = await response.json();
|
| 38 |
+
return data;
|
| 39 |
+
} catch (error) {
|
| 40 |
+
if (error instanceof Error) {
|
| 41 |
+
throw error;
|
| 42 |
+
}
|
| 43 |
+
throw new Error('Failed to generate enhanced argument');
|
| 44 |
+
}
|
| 45 |
+
}
|
src/app/services/groq.service.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ChatMessage } from '../types/chat.types.ts';
|
| 2 |
+
|
| 3 |
+
const GROQ_API_URL = 'https://api.groq.com/openai/v1';
|
| 4 |
+
const MODEL = 'meta-llama/llama-4-scout-17b-16e-instruct';
|
| 5 |
+
|
| 6 |
+
export type GroqMessage = {
|
| 7 |
+
role: 'user' | 'assistant' | 'system';
|
| 8 |
+
content: string;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export type GroqChatResponse = {
|
| 12 |
+
id: string;
|
| 13 |
+
object: string;
|
| 14 |
+
created: number;
|
| 15 |
+
model: string;
|
| 16 |
+
choices: Array<{
|
| 17 |
+
index: number;
|
| 18 |
+
message: {
|
| 19 |
+
role: 'assistant';
|
| 20 |
+
content: string;
|
| 21 |
+
};
|
| 22 |
+
finish_reason: string;
|
| 23 |
+
}>;
|
| 24 |
+
usage: {
|
| 25 |
+
prompt_tokens: number;
|
| 26 |
+
completion_tokens: number;
|
| 27 |
+
total_tokens: number;
|
| 28 |
+
};
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
export type GroqStreamChunk = {
|
| 32 |
+
id: string;
|
| 33 |
+
object: string;
|
| 34 |
+
created: number;
|
| 35 |
+
model: string;
|
| 36 |
+
choices: Array<{
|
| 37 |
+
index: number;
|
| 38 |
+
delta: {
|
| 39 |
+
role?: 'assistant';
|
| 40 |
+
content?: string;
|
| 41 |
+
};
|
| 42 |
+
finish_reason: string | null;
|
| 43 |
+
}>;
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
export type GroqError = {
|
| 47 |
+
error: {
|
| 48 |
+
message: string;
|
| 49 |
+
type: string;
|
| 50 |
+
code?: string;
|
| 51 |
+
};
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Sends messages to Groq API and returns the assistant's response
|
| 56 |
+
*/
|
| 57 |
+
export async function sendChatMessage(messages: ChatMessage[]): Promise<string> {
|
| 58 |
+
const apiKey = process.env.REACT_APP_GROQ_API_KEY;
|
| 59 |
+
|
| 60 |
+
if (!apiKey) {
|
| 61 |
+
throw new Error('REACT_APP_GROQ_API_KEY environment variable is not set');
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Convert our ChatMessage format to Groq's expected format
|
| 65 |
+
const groqMessages: GroqMessage[] = messages.map(msg => ({
|
| 66 |
+
role: msg.role,
|
| 67 |
+
content: msg.content
|
| 68 |
+
}));
|
| 69 |
+
|
| 70 |
+
try {
|
| 71 |
+
const response = await fetch(`${GROQ_API_URL}/chat/completions`, {
|
| 72 |
+
method: 'POST',
|
| 73 |
+
headers: {
|
| 74 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 75 |
+
'Content-Type': 'application/json',
|
| 76 |
+
},
|
| 77 |
+
body: JSON.stringify({
|
| 78 |
+
model: MODEL,
|
| 79 |
+
messages: groqMessages,
|
| 80 |
+
temperature: 0.7,
|
| 81 |
+
max_tokens: 4096,
|
| 82 |
+
top_p: 1,
|
| 83 |
+
stream: true,
|
| 84 |
+
}),
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
if (!response.ok) {
|
| 88 |
+
const errorData: GroqError = await response.json();
|
| 89 |
+
throw new Error(errorData.error?.message || `HTTP error! status: ${response.status}`);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const reader = response.body?.getReader();
|
| 93 |
+
if (!reader) {
|
| 94 |
+
throw new Error('Failed to get response reader');
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const decoder = new TextDecoder();
|
| 98 |
+
let fullContent = '';
|
| 99 |
+
|
| 100 |
+
while (true) {
|
| 101 |
+
const { done, value } = await reader.read();
|
| 102 |
+
if (done) break;
|
| 103 |
+
|
| 104 |
+
const chunk = decoder.decode(value, { stream: true });
|
| 105 |
+
const lines = chunk.split('\n');
|
| 106 |
+
|
| 107 |
+
for (const line of lines) {
|
| 108 |
+
if (line.startsWith('data: ')) {
|
| 109 |
+
const data = line.slice(6);
|
| 110 |
+
if (data === '[DONE]') continue;
|
| 111 |
+
|
| 112 |
+
try {
|
| 113 |
+
const parsed: GroqStreamChunk = JSON.parse(data);
|
| 114 |
+
const content = parsed.choices[0]?.delta?.content;
|
| 115 |
+
if (content) {
|
| 116 |
+
fullContent += content;
|
| 117 |
+
}
|
| 118 |
+
} catch (e) {
|
| 119 |
+
// Skip invalid JSON
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return fullContent;
|
| 126 |
+
} catch (error) {
|
| 127 |
+
if (error instanceof Error) {
|
| 128 |
+
throw error;
|
| 129 |
+
}
|
| 130 |
+
throw new Error('Failed to send message to Groq API');
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* Sends messages to Groq API with streaming support
|
| 136 |
+
*/
|
| 137 |
+
export async function sendChatMessageStream(
|
| 138 |
+
messages: ChatMessage[],
|
| 139 |
+
onChunk: (chunk: string) => void
|
| 140 |
+
): Promise<string> {
|
| 141 |
+
const apiKey = process.env.REACT_APP_GROQ_API_KEY;
|
| 142 |
+
|
| 143 |
+
if (!apiKey) {
|
| 144 |
+
throw new Error('REACT_APP_GROQ_API_KEY environment variable is not set');
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// Convert our ChatMessage format to Groq's expected format
|
| 148 |
+
const groqMessages: GroqMessage[] = messages.map(msg => ({
|
| 149 |
+
role: msg.role,
|
| 150 |
+
content: msg.content
|
| 151 |
+
}));
|
| 152 |
+
|
| 153 |
+
try {
|
| 154 |
+
const response = await fetch(`${GROQ_API_URL}/chat/completions`, {
|
| 155 |
+
method: 'POST',
|
| 156 |
+
headers: {
|
| 157 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 158 |
+
'Content-Type': 'application/json',
|
| 159 |
+
},
|
| 160 |
+
body: JSON.stringify({
|
| 161 |
+
model: MODEL,
|
| 162 |
+
messages: groqMessages,
|
| 163 |
+
temperature: 0.7,
|
| 164 |
+
max_tokens: 4096,
|
| 165 |
+
top_p: 1,
|
| 166 |
+
stream: true,
|
| 167 |
+
}),
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
if (!response.ok) {
|
| 171 |
+
const errorData: GroqError = await response.json();
|
| 172 |
+
throw new Error(errorData.error?.message || `HTTP error! status: ${response.status}`);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const reader = response.body?.getReader();
|
| 176 |
+
if (!reader) {
|
| 177 |
+
throw new Error('Failed to get response reader');
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const decoder = new TextDecoder();
|
| 181 |
+
let fullContent = '';
|
| 182 |
+
|
| 183 |
+
while (true) {
|
| 184 |
+
const { done, value } = await reader.read();
|
| 185 |
+
if (done) break;
|
| 186 |
+
|
| 187 |
+
const chunk = decoder.decode(value, { stream: true });
|
| 188 |
+
const lines = chunk.split('\n');
|
| 189 |
+
|
| 190 |
+
for (const line of lines) {
|
| 191 |
+
if (line.startsWith('data: ')) {
|
| 192 |
+
const data = line.slice(6);
|
| 193 |
+
if (data === '[DONE]') continue;
|
| 194 |
+
|
| 195 |
+
try {
|
| 196 |
+
const parsed: GroqStreamChunk = JSON.parse(data);
|
| 197 |
+
const content = parsed.choices[0]?.delta?.content;
|
| 198 |
+
if (content) {
|
| 199 |
+
fullContent += content;
|
| 200 |
+
onChunk(content);
|
| 201 |
+
}
|
| 202 |
+
} catch (e) {
|
| 203 |
+
// Skip invalid JSON
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
return fullContent;
|
| 210 |
+
} catch (error) {
|
| 211 |
+
if (error instanceof Error) {
|
| 212 |
+
throw error;
|
| 213 |
+
}
|
| 214 |
+
throw new Error('Failed to send message to Groq API');
|
| 215 |
+
}
|
| 216 |
+
}
|
src/app/types/chat.types.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type MessageRole = 'user' | 'assistant' | 'system';
|
| 2 |
+
|
| 3 |
+
export type ChatMessage = {
|
| 4 |
+
id: string;
|
| 5 |
+
role: MessageRole;
|
| 6 |
+
content: string;
|
| 7 |
+
timestamp: Date;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export type ChatState = {
|
| 11 |
+
messages: ChatMessage[];
|
| 12 |
+
isLoading: boolean;
|
| 13 |
+
error: string | null;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export type ChatHistory = {
|
| 17 |
+
messages: ChatMessage[];
|
| 18 |
+
createdAt: Date;
|
| 19 |
+
updatedAt: Date;
|
| 20 |
+
};
|
src/app/types/index.ts
CHANGED
|
@@ -7,4 +7,5 @@ export * from './api.types.ts';
|
|
| 7 |
export * from './analysis.types.ts';
|
| 8 |
export * from './user.types.ts';
|
| 9 |
export * from './mcp.types.ts';
|
|
|
|
| 10 |
|
|
|
|
| 7 |
export * from './analysis.types.ts';
|
| 8 |
export * from './user.types.ts';
|
| 9 |
export * from './mcp.types.ts';
|
| 10 |
+
export * from './chat.types.ts';
|
| 11 |
|