mirror of
https://github.com/james-m-jordan/morphik-core.git
synced 2025-05-09 19:32:38 +00:00
477 lines
18 KiB
TypeScript
477 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useMorphikChat } from '@/hooks/useMorphikChat';
|
|
import { ChatMessage, Folder } from '@/components/types';
|
|
import { generateUUID } from '@/lib/utils';
|
|
|
|
import { Settings, Spin, ArrowUp } from './icons';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { PreviewMessage } from './ChatMessages';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Slider } from '@/components/ui/slider';
|
|
|
|
interface ChatSectionProps {
|
|
apiBaseUrl: string;
|
|
authToken: string | null;
|
|
initialMessages?: ChatMessage[];
|
|
isReadonly?: boolean;
|
|
}
|
|
|
|
/**
|
|
* ChatSection component using Vercel-style UI
|
|
*/
|
|
const ChatSection: React.FC<ChatSectionProps> = ({
|
|
apiBaseUrl,
|
|
authToken,
|
|
initialMessages = [],
|
|
isReadonly = false
|
|
}) => {
|
|
// Generate a unique chat ID if not provided
|
|
const chatId = generateUUID();
|
|
|
|
// Initialize our custom hook
|
|
const {
|
|
messages,
|
|
input,
|
|
setInput,
|
|
status,
|
|
handleSubmit,
|
|
queryOptions,
|
|
updateQueryOption
|
|
} = useMorphikChat(chatId, apiBaseUrl, authToken, initialMessages);
|
|
|
|
// State for settings visibility
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [availableGraphs, setAvailableGraphs] = useState<string[]>([]);
|
|
const [loadingGraphs, setLoadingGraphs] = useState(false);
|
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
|
const [folders, setFolders] = useState<Folder[]>([]);
|
|
|
|
// Fetch available graphs for dropdown
|
|
const fetchGraphs = useCallback(async () => {
|
|
if (!apiBaseUrl) return;
|
|
|
|
setLoadingGraphs(true);
|
|
try {
|
|
console.log(`Fetching graphs from: ${apiBaseUrl}/graphs`);
|
|
const response = await fetch(`${apiBaseUrl}/graphs`, {
|
|
headers: {
|
|
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch graphs: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const graphsData = await response.json();
|
|
console.log('Graphs data received:', graphsData);
|
|
|
|
if (Array.isArray(graphsData)) {
|
|
setAvailableGraphs(graphsData.map((graph: { name: string }) => graph.name));
|
|
} else {
|
|
console.error('Expected array for graphs data but received:', typeof graphsData);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching available graphs:', err);
|
|
} finally {
|
|
setLoadingGraphs(false);
|
|
}
|
|
}, [apiBaseUrl, authToken]);
|
|
|
|
// Fetch folders
|
|
const fetchFolders = useCallback(async () => {
|
|
if (!apiBaseUrl) return;
|
|
|
|
setLoadingFolders(true);
|
|
try {
|
|
console.log(`Fetching folders from: ${apiBaseUrl}/folders`);
|
|
const response = await fetch(`${apiBaseUrl}/folders`, {
|
|
headers: {
|
|
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch folders: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const foldersData = await response.json();
|
|
console.log('Folders data received:', foldersData);
|
|
|
|
if (Array.isArray(foldersData)) {
|
|
setFolders(foldersData);
|
|
} else {
|
|
console.error('Expected array for folders data but received:', typeof foldersData);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching folders:', err);
|
|
} finally {
|
|
setLoadingFolders(false);
|
|
}
|
|
}, [apiBaseUrl, authToken]);
|
|
|
|
// Fetch graphs and folders when component mounts
|
|
useEffect(() => {
|
|
// Define a function to handle data fetching
|
|
const fetchData = async () => {
|
|
if (authToken || apiBaseUrl.includes('localhost')) {
|
|
console.log('ChatSection: Fetching data with auth token:', !!authToken);
|
|
await fetchGraphs();
|
|
await fetchFolders();
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, [authToken, apiBaseUrl, fetchGraphs, fetchFolders]);
|
|
|
|
// Text area ref and adjustment functions
|
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (textareaRef.current) {
|
|
adjustHeight();
|
|
}
|
|
}, []);
|
|
|
|
const adjustHeight = () => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
|
|
}
|
|
};
|
|
|
|
const resetHeight = () => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
}
|
|
};
|
|
|
|
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setInput(event.target.value);
|
|
adjustHeight();
|
|
};
|
|
|
|
const submitForm = () => {
|
|
handleSubmit();
|
|
resetHeight();
|
|
if (textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
}
|
|
};
|
|
|
|
// Messages container ref for scrolling
|
|
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
|
|
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// Scroll to bottom when messages change
|
|
React.useEffect(() => {
|
|
if (messagesEndRef.current) {
|
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [messages]);
|
|
|
|
return (
|
|
<div className="relative flex flex-col h-full w-full bg-background">
|
|
{/* Chat Header
|
|
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm">
|
|
<div className="flex h-14 items-center justify-between px-4 border-b">
|
|
<div className="flex items-center">
|
|
<h1 className="font-semibold text-lg tracking-tight">Morphik Chat</h1>
|
|
</div>
|
|
</div>
|
|
</div> */}
|
|
|
|
{/* Messages Area */}
|
|
<div className="flex-1 relative">
|
|
<ScrollArea className="h-full" ref={messagesContainerRef}>
|
|
{messages.length === 0 && (
|
|
<div className="flex-1 flex items-center justify-center p-8 text-center">
|
|
<div className="max-w-md space-y-2">
|
|
<h2 className="text-xl font-semibold">Welcome to Morphik Chat</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Ask a question about your documents to get started.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col pt-4 pb-[80px] md:pb-[120px]">
|
|
{messages.map((message) => (
|
|
<PreviewMessage
|
|
key={message.id}
|
|
message={message}
|
|
/>
|
|
))}
|
|
|
|
{status === 'submitted' &&
|
|
messages.length > 0 &&
|
|
messages[messages.length - 1].role === 'user' && (
|
|
<div className="flex items-center justify-center h-12 text-center text-xs text-muted-foreground">
|
|
<Spin className="mr-2 animate-spin" />
|
|
Thinking...
|
|
</div>
|
|
)
|
|
}
|
|
</div>
|
|
|
|
<div
|
|
ref={messagesEndRef}
|
|
className="shrink-0 min-w-[24px] min-h-[24px]"
|
|
/>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<div className="sticky bottom-0 w-full bg-background">
|
|
<div className="mx-auto px-4 sm:px-6 max-w-4xl">
|
|
<form
|
|
className="pb-6"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
handleSubmit(e);
|
|
}}
|
|
>
|
|
<div className="relative w-full">
|
|
<div className="absolute left-0 right-0 -top-20 h-24 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
|
<div className="relative flex items-end">
|
|
<Textarea
|
|
ref={textareaRef}
|
|
placeholder="Send a message..."
|
|
value={input}
|
|
onChange={handleInput}
|
|
className="min-h-[48px] max-h-[400px] resize-none overflow-hidden text-base w-full pr-16"
|
|
rows={1}
|
|
autoFocus
|
|
onKeyDown={(event) => {
|
|
if (
|
|
event.key === 'Enter' &&
|
|
!event.shiftKey &&
|
|
!event.nativeEvent.isComposing
|
|
) {
|
|
event.preventDefault();
|
|
|
|
if (status !== 'ready') {
|
|
console.log('Please wait for the model to finish its response');
|
|
} else {
|
|
submitForm();
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<div className="absolute bottom-2 right-2 flex items-center">
|
|
<Button
|
|
onClick={submitForm}
|
|
size="icon"
|
|
disabled={input.trim().length === 0 || status !== 'ready'}
|
|
className="h-8 w-8 rounded-full flex items-center justify-center"
|
|
>
|
|
{status === 'streaming' ? (
|
|
<Spin className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<ArrowUp className="h-4 w-4" />
|
|
)}
|
|
<span className="sr-only">
|
|
{status === 'streaming' ? 'Processing' : 'Send message'}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Settings Button */}
|
|
{!isReadonly && (
|
|
<div className="flex justify-center mt-4">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
|
onClick={() => {
|
|
setShowSettings(!showSettings);
|
|
// Refresh data when opening settings
|
|
if (!showSettings && authToken) {
|
|
fetchGraphs();
|
|
fetchFolders();
|
|
}
|
|
}}
|
|
>
|
|
<Settings className="h-3.5 w-3.5" />
|
|
<span>{showSettings ? 'Hide Settings' : 'Show Settings'}</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Settings Panel */}
|
|
{showSettings && !isReadonly && (
|
|
<div className="mt-4 border rounded-xl p-4 bg-muted/30 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold text-sm">Chat Settings</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs"
|
|
onClick={() => setShowSettings(false)}
|
|
>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* First Column - Core Settings */}
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="use_reranking" className="text-sm">Use Reranking</Label>
|
|
<Switch
|
|
id="use_reranking"
|
|
checked={queryOptions.use_reranking}
|
|
onCheckedChange={(checked) => updateQueryOption('use_reranking', checked)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="use_colpali" className="text-sm">Use Colpali</Label>
|
|
<Switch
|
|
id="use_colpali"
|
|
checked={queryOptions.use_colpali}
|
|
onCheckedChange={(checked) => updateQueryOption('use_colpali', checked)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="graph_name" className="text-sm block">Knowledge Graph</Label>
|
|
<Select
|
|
value={queryOptions.graph_name || "__none__"}
|
|
onValueChange={(value) => updateQueryOption('graph_name', value === "__none__" ? undefined : value)}
|
|
>
|
|
<SelectTrigger className="w-full" id="graph_name">
|
|
<SelectValue placeholder="Select a knowledge graph" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">None (Standard RAG)</SelectItem>
|
|
{loadingGraphs ? (
|
|
<SelectItem value="loading" disabled>Loading graphs...</SelectItem>
|
|
) : availableGraphs.length > 0 ? (
|
|
availableGraphs.map(graphName => (
|
|
<SelectItem key={graphName} value={graphName}>
|
|
{graphName}
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<SelectItem value="none_available" disabled>No graphs available</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="folder_name" className="text-sm block">Scope to Folder</Label>
|
|
<Select
|
|
value={queryOptions.folder_name || "__none__"}
|
|
onValueChange={(value) => updateQueryOption('folder_name', value === "__none__" ? undefined : value)}
|
|
>
|
|
<SelectTrigger className="w-full" id="folder_name">
|
|
<SelectValue placeholder="Select a folder" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">All Folders</SelectItem>
|
|
{loadingFolders ? (
|
|
<SelectItem value="loading" disabled>Loading folders...</SelectItem>
|
|
) : folders.length > 0 ? (
|
|
folders.map(folder => (
|
|
<SelectItem key={folder.id || folder.name} value={folder.name}>
|
|
{folder.name}
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<SelectItem value="none_available" disabled>No folders available</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Second Column - Advanced Settings */}
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="query-k" className="text-sm flex justify-between">
|
|
<span>Results (k)</span>
|
|
<span className="text-muted-foreground">{queryOptions.k}</span>
|
|
</Label>
|
|
<Slider
|
|
id="query-k"
|
|
min={1}
|
|
max={20}
|
|
step={1}
|
|
value={[queryOptions.k]}
|
|
onValueChange={(value) => updateQueryOption('k', value[0])}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="query-min-score" className="text-sm flex justify-between">
|
|
<span>Min Score</span>
|
|
<span className="text-muted-foreground">{queryOptions.min_score.toFixed(2)}</span>
|
|
</Label>
|
|
<Slider
|
|
id="query-min-score"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={[queryOptions.min_score]}
|
|
onValueChange={(value) => updateQueryOption('min_score', value[0])}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="query-temperature" className="text-sm flex justify-between">
|
|
<span>Temperature</span>
|
|
<span className="text-muted-foreground">{queryOptions.temperature.toFixed(2)}</span>
|
|
</Label>
|
|
<Slider
|
|
id="query-temperature"
|
|
min={0}
|
|
max={2}
|
|
step={0.01}
|
|
value={[queryOptions.temperature]}
|
|
onValueChange={(value) => updateQueryOption('temperature', value[0])}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="query-max-tokens" className="text-sm flex justify-between">
|
|
<span>Max Tokens</span>
|
|
<span className="text-muted-foreground">{queryOptions.max_tokens}</span>
|
|
</Label>
|
|
<Slider
|
|
id="query-max-tokens"
|
|
min={1}
|
|
max={2048}
|
|
step={1}
|
|
value={[queryOptions.max_tokens]}
|
|
onValueChange={(value) => updateQueryOption('max_tokens', value[0])}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ChatSection;
|