Make UI comp tastier (#119)

This commit is contained in:
Adityavardhan Agrawal 2025-04-26 23:18:32 -07:00 committed by GitHub
parent 8079b6d51e
commit e8b7f6789b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1683 additions and 1789 deletions

View File

@ -66,10 +66,29 @@ const ForceGraphComponent: React.FC<ForceGraphComponentProps> = ({
try {
// Dynamic import
const ForceGraphModule = await import('force-graph');
// Get the ForceGraph constructor function
const ForceGraphConstructor = ForceGraphModule.default;
// Get theme colors from CSS variables for links only
const computedStyle = getComputedStyle(containerRef.current!);
// Use muted-foreground for links, convert HSL string to RGB and then add alpha
let linkColor = 'rgba(128, 128, 128, 0.3)'; // Default fallback grey
let arrowColor = 'rgba(128, 128, 128, 0.6)'; // Default fallback grey
const mutedFg = computedStyle.getPropertyValue('--muted-foreground').trim();
if (mutedFg) {
// Attempt to parse HSL color (format: <hue> <saturation>% <lightness>%)
const hslMatch = mutedFg.match(/^(\d+(?:.\d+)?)\s+(\d+(?:.\d+)?)%\s+(\d+(?:.\d+)?)%$/);
if (hslMatch) {
const [, h, s, l] = hslMatch.map(Number);
const rgb = hslToRgb(h / 360, s / 100, l / 100);
linkColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.3)`;
arrowColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.6)`;
} else {
// Fallback if not HSL (e.g., direct hex or rgb - unlikely for shadcn)
console.warn('Could not parse --muted-foreground HSL value, using default link color.');
}
}
// Create a new graph instance using the 'new' keyword
if (containerRef.current) {
graphInstance = new ForceGraphConstructor(containerRef.current) as ForceGraphInstance;
@ -95,40 +114,25 @@ const ForceGraphComponent: React.FC<ForceGraphComponentProps> = ({
// Always use nodeCanvasObject to have consistent rendering regardless of label visibility
if (graph.nodeCanvasObject) {
graph.nodeCanvasObject((node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
// Draw the node circle
const nodeR = 5;
if (typeof node.x !== 'number' || typeof node.y !== 'number') return;
const x = node.x;
const y = node.y;
ctx.beginPath();
ctx.arc(x, y, nodeR, 0, 2 * Math.PI);
ctx.fillStyle = node.color;
ctx.fill();
// Only draw the text label if showNodeLabels is true
if (showNodeLabels) {
const label = node.label;
const fontSize = 12/globalScale;
ctx.font = `${fontSize}px Sans-Serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Add a background for better readability
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fillRect(
x - bckgDimensions[0] / 2,
y - bckgDimensions[1] / 2,
bckgDimensions[0],
bckgDimensions[1]
);
ctx.fillRect(x - bckgDimensions[0] / 2, y - bckgDimensions[1] / 2, bckgDimensions[0], bckgDimensions[1]);
ctx.fillStyle = 'black';
ctx.fillText(label, x, y);
}
@ -138,10 +142,8 @@ const ForceGraphComponent: React.FC<ForceGraphComponentProps> = ({
// Always use linkCanvasObject for consistent rendering
if (graph.linkCanvasObject) {
graph.linkCanvasObject((link: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
// Draw the link line
const start = link.source as NodeObject;
const end = link.target as NodeObject;
if (!start || !end || typeof start.x !== 'number' || typeof end.x !== 'number' ||
typeof start.y !== 'number' || typeof end.y !== 'number') return;
@ -150,61 +152,43 @@ const ForceGraphComponent: React.FC<ForceGraphComponentProps> = ({
const endX = end.x;
const endY = end.y;
// Draw the link line with theme color
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
ctx.strokeStyle = linkColor;
ctx.lineWidth = 1;
ctx.stroke();
// Draw arrowhead regardless of label visibility
// Draw arrowhead with theme color
const arrowLength = 5;
const dx = endX - startX;
const dy = endY - startY;
const angle = Math.atan2(dy, dx);
// Calculate a position near the target for the arrow
const arrowDistance = 15; // Distance from target node
const arrowDistance = 15;
const arrowX = endX - Math.cos(angle) * arrowDistance;
const arrowY = endY - Math.sin(angle) * arrowDistance;
ctx.beginPath();
ctx.moveTo(arrowX, arrowY);
ctx.lineTo(
arrowX - arrowLength * Math.cos(angle - Math.PI / 6),
arrowY - arrowLength * Math.sin(angle - Math.PI / 6)
);
ctx.lineTo(
arrowX - arrowLength * Math.cos(angle + Math.PI / 6),
arrowY - arrowLength * Math.sin(angle + Math.PI / 6)
);
ctx.lineTo(arrowX - arrowLength * Math.cos(angle - Math.PI / 6), arrowY - arrowLength * Math.sin(angle - Math.PI / 6));
ctx.lineTo(arrowX - arrowLength * Math.cos(angle + Math.PI / 6), arrowY - arrowLength * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillStyle = arrowColor;
ctx.fill();
// Only draw label if showLinkLabels is true
// Keep original label rendering
if (showLinkLabels) {
const label = link.type;
if (label) {
const fontSize = 10/globalScale;
ctx.font = `${fontSize}px Sans-Serif`;
// Calculate middle point
const middleX = startX + (endX - startX) / 2;
const middleY = startY + (endY - startY) / 2;
// Add a background for better readability
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fillRect(
middleX - bckgDimensions[0] / 2,
middleY - bckgDimensions[1] / 2,
bckgDimensions[0],
bckgDimensions[1]
);
ctx.fillRect(middleX - bckgDimensions[0] / 2, middleY - bckgDimensions[1] / 2, bckgDimensions[0], bckgDimensions[1]);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'black';
@ -233,13 +217,41 @@ const ForceGraphComponent: React.FC<ForceGraphComponentProps> = ({
}
};
// HSL to RGB conversion function (needed because canvas needs RGB)
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
initGraph();
// Cleanup function
const currentContainer = containerRef.current; // Store ref value
return () => {
if (graphInstance && typeof graphInstance._destructor === 'function') {
graphInstance._destructor();
}
// Ensure container is cleared on cleanup too
if (currentContainer) { // Use the stored value in cleanup
currentContainer.innerHTML = '';
}
};
}, [data, width, height, showNodeLabels, showLinkLabels]);

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ import DocumentsSection from '@/components/documents/DocumentsSection';
import SearchSection from '@/components/search/SearchSection';
import ChatSection from '@/components/chat/ChatSection';
import GraphSection from '@/components/GraphSection';
import { Badge } from '@/components/ui/badge';
import { AlertSystem } from '@/components/ui/alert-system';
import { extractTokenFromUri, getApiBaseUrlFromUri } from '@/lib/utils';
import { MorphikUIProps } from '@/components/types';
@ -40,7 +39,6 @@ const MorphikUI: React.FC<MorphikUIProps> = ({
}
};
const [activeSection, setActiveSection] = useState(initialSection);
const [selectedGraphName, setSelectedGraphName] = useState<string | undefined>(undefined);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
// Extract auth token and API URL from connection URI if provided
@ -105,18 +103,9 @@ const MorphikUI: React.FC<MorphikUIProps> = ({
{/* Graphs Section */}
{activeSection === 'graphs' && (
<div className="space-y-4">
<div className="flex justify-end items-center">
{selectedGraphName && (
<Badge variant="outline" className="bg-blue-50 px-3 py-1">
Current Query Graph: {selectedGraphName}
</Badge>
)}
</div>
<GraphSection
apiBaseUrl={effectiveApiBaseUrl}
authToken={authToken}
onSelectGraph={(graphName) => setSelectedGraphName(graphName)}
/>
</div>
)}

View File

@ -1,115 +0,0 @@
"use client";
import React from 'react';
import Image from 'next/image';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { Source } from '@/components/types';
// Define our own props interface to avoid empty interface error
interface ChatMessageProps {
role: 'user' | 'assistant';
content: string;
sources?: Source[];
}
const ChatMessageComponent: React.FC<ChatMessageProps> = ({ role, content, sources }) => {
// Helper to render content based on content type
const renderContent = (content: string, contentType: string) => {
if (contentType.startsWith('image/')) {
return (
<div className="flex justify-center p-4 bg-muted rounded-md">
<Image
src={content}
alt="Document content"
className="max-w-full max-h-96 object-contain"
width={500}
height={300}
/>
</div>
);
} else if (content.startsWith('data:image/png;base64,') || content.startsWith('data:image/jpeg;base64,')) {
return (
<div className="flex justify-center p-4 bg-muted rounded-md">
<Image
src={content}
alt="Base64 image content"
className="max-w-full max-h-96 object-contain"
width={500}
height={300}
/>
</div>
);
} else {
return (
<div className="bg-muted p-4 rounded-md whitespace-pre-wrap font-mono text-sm">
{content}
</div>
);
}
};
return (
<div className={`flex ${role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-3/4 p-3 rounded-lg ${
role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
<div className="whitespace-pre-wrap">{content}</div>
{sources && sources.length > 0 && role === 'assistant' && (
<Accordion type="single" collapsible className="mt-4">
<AccordionItem value="sources">
<AccordionTrigger className="text-xs">Sources ({sources.length})</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
{sources.map((source, index) => (
<div key={`${source.document_id}-${source.chunk_number}-${index}`} className="bg-background p-2 rounded text-xs border">
<div className="pb-2">
<div className="flex justify-between items-start">
<div>
<span className="font-medium">
{source.filename || `Document ${source.document_id.substring(0, 8)}...`}
</span>
<span className="text-muted-foreground ml-1">
Chunk {source.chunk_number} {source.score !== undefined && `• Score: ${source.score.toFixed(2)}`}
</span>
</div>
{source.content_type && (
<Badge variant="outline" className="text-[10px]">
{source.content_type}
</Badge>
)}
</div>
</div>
{source.content && (
renderContent(source.content, source.content_type || 'text/plain')
)}
<Accordion type="single" collapsible className="mt-3">
<AccordionItem value="metadata">
<AccordionTrigger className="text-[10px]">Metadata</AccordionTrigger>
<AccordionContent>
<pre className="bg-muted p-1 rounded text-[10px] overflow-x-auto">
{JSON.stringify(source.metadata, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</div>
);
};
export default ChatMessageComponent;

View File

@ -0,0 +1,149 @@
import React from 'react';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { Spin } from './icons';
import Image from 'next/image';
import { Source } from '@/components/types';
// Define interface for the UIMessage - matching what our useMorphikChat hook returns
export interface UIMessage {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: Date;
experimental_customData?: { sources: Source[] };
}
export interface MessageProps {
chatId: string;
message: UIMessage;
isLoading?: boolean;
setMessages: (messages: UIMessage[]) => void;
reload: () => void;
isReadonly: boolean;
}
export function ThinkingMessage() {
return (
<div className="flex items-center justify-center h-12 text-center text-xs text-muted-foreground">
<Spin className="mr-2 animate-spin" />
Thinking...
</div>
);
}
// Helper to render source content based on content type
const renderContent = (content: string, contentType: string) => {
if (contentType.startsWith('image/')) {
return (
<div className="flex justify-center p-4 bg-muted rounded-md">
<Image
src={content}
alt="Document content"
className="max-w-full max-h-96 object-contain"
width={500}
height={300}
/>
</div>
);
} else if (content.startsWith('data:image/png;base64,') || content.startsWith('data:image/jpeg;base64,')) {
return (
<div className="flex justify-center p-4 bg-muted rounded-md">
<Image
src={content}
alt="Base64 image content"
className="max-w-full max-h-96 object-contain"
width={500}
height={300}
/>
</div>
);
} else {
return (
<div className="bg-muted p-4 rounded-md whitespace-pre-wrap font-mono text-sm">
{content}
</div>
);
}
};
export function PreviewMessage({ message }: Pick<MessageProps, 'message'>) {
const sources = message.experimental_customData?.sources;
return (
<div className="px-4 py-3 flex group relative">
<div className={`flex flex-col w-full ${message.role === 'user' ? 'items-end' : 'items-start'}`}>
<div className="flex items-start gap-4 w-full max-w-3xl">
<div className={`flex-1 space-y-2 overflow-hidden ${message.role === 'user' ? '' : ''}`}>
<div className={`p-4 rounded-xl ${
message.role === 'user'
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted'
}`}>
<div className="prose prose-sm dark:prose-invert break-words">
{message.content}
</div>
</div>
{sources && sources.length > 0 && message.role === 'assistant' && (
<Accordion type="single" collapsible className="mt-2 border rounded-xl overflow-hidden">
<AccordionItem value="sources" className="border-0">
<AccordionTrigger className="px-4 py-2 text-sm font-medium">
Sources ({sources.length})
</AccordionTrigger>
<AccordionContent className="px-4 pb-3">
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2">
{sources.map((source, index) => (
<div key={`${source.document_id}-${source.chunk_number}-${index}`}
className="bg-background rounded-md border overflow-hidden">
<div className="p-3 border-b">
<div className="flex justify-between items-start">
<div>
<span className="font-medium text-sm">
{source.filename || `Document ${source.document_id.substring(0, 8)}...`}
</span>
<div className="text-xs text-muted-foreground mt-0.5">
Chunk {source.chunk_number} {source.score !== undefined && `• Score: ${source.score.toFixed(2)}`}
</div>
</div>
{source.content_type && (
<Badge variant="outline" className="text-[10px]">
{source.content_type}
</Badge>
)}
</div>
</div>
{source.content && (
<div className="px-3 py-2">
{renderContent(source.content, source.content_type || 'text/plain')}
</div>
)}
<Accordion type="single" collapsible className="border-t">
<AccordionItem value="metadata" className="border-0">
<AccordionTrigger className="px-3 py-2 text-xs">
Metadata
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto">
{JSON.stringify(source.metadata, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,190 +0,0 @@
"use client";
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Settings } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { QueryOptions, Folder } from '@/components/types';
interface ChatOptionsDialogProps {
showChatAdvanced: boolean;
setShowChatAdvanced: (show: boolean) => void;
queryOptions: QueryOptions;
updateQueryOption: <K extends keyof QueryOptions>(key: K, value: QueryOptions[K]) => void;
availableGraphs: string[];
folders: Folder[];
}
const ChatOptionsDialog: React.FC<ChatOptionsDialogProps> = ({
showChatAdvanced,
setShowChatAdvanced,
queryOptions,
updateQueryOption,
availableGraphs,
folders
}) => {
return (
<Dialog open={showChatAdvanced} onOpenChange={setShowChatAdvanced}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="flex items-center">
<Settings className="mr-2 h-4 w-4" />
Advanced Options
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Chat Options</DialogTitle>
<DialogDescription>
Configure advanced chat parameters
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div>
<Label htmlFor="query-filters" className="block mb-2">Filters (JSON)</Label>
<Textarea
id="query-filters"
value={queryOptions.filters}
onChange={(e) => updateQueryOption('filters', e.target.value)}
placeholder='{"key": "value"}'
rows={3}
/>
</div>
<div>
<Label htmlFor="query-k" className="block mb-2">
Number of Results (k): {queryOptions.k}
</Label>
<Input
id="query-k"
type="number"
min={1}
value={queryOptions.k}
onChange={(e) => updateQueryOption('k', parseInt(e.target.value) || 1)}
/>
</div>
<div>
<Label htmlFor="query-min-score" className="block mb-2">
Minimum Score: {queryOptions.min_score.toFixed(2)}
</Label>
<Input
id="query-min-score"
type="number"
min={0}
max={1}
step={0.01}
value={queryOptions.min_score}
onChange={(e) => updateQueryOption('min_score', parseFloat(e.target.value) || 0)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="query-reranking">Use Reranking</Label>
<Switch
id="query-reranking"
checked={queryOptions.use_reranking}
onCheckedChange={(checked) => updateQueryOption('use_reranking', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="query-colpali">Use Colpali</Label>
<Switch
id="query-colpali"
checked={queryOptions.use_colpali}
onCheckedChange={(checked) => updateQueryOption('use_colpali', checked)}
/>
</div>
<div>
<Label htmlFor="query-max-tokens" className="block mb-2">
Max Tokens: {queryOptions.max_tokens}
</Label>
<Input
id="query-max-tokens"
type="number"
min={1}
max={2048}
value={queryOptions.max_tokens}
onChange={(e) => updateQueryOption('max_tokens', parseInt(e.target.value) || 1)}
/>
</div>
<div>
<Label htmlFor="query-temperature" className="block mb-2">
Temperature: {queryOptions.temperature.toFixed(2)}
</Label>
<Input
id="query-temperature"
type="number"
min={0}
max={2}
step={0.01}
value={queryOptions.temperature}
onChange={(e) => updateQueryOption('temperature', parseFloat(e.target.value) || 0)}
/>
</div>
<div>
<Label htmlFor="graphName" className="block mb-2">Knowledge Graph</Label>
<Select
value={queryOptions.graph_name || "__none__"}
onValueChange={(value) => updateQueryOption('graph_name', value === "__none__" ? undefined : value)}
>
<SelectTrigger className="w-full" id="graphName">
<SelectValue placeholder="Select a knowledge graph" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None (Standard RAG)</SelectItem>
{availableGraphs.map(graphName => (
<SelectItem key={graphName} value={graphName}>
{graphName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-1">
Select a knowledge graph to enhance your query with structured relationships
</p>
</div>
<div>
<Label htmlFor="folderName" className="block mb-2">Scope to Folder</Label>
<Select
value={queryOptions.folder_name || "__none__"}
onValueChange={(value) => updateQueryOption('folder_name', value === "__none__" ? undefined : value)}
>
<SelectTrigger className="w-full" id="folderName">
<SelectValue placeholder="Select a folder" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">All Folders</SelectItem>
{folders.map(folder => (
<SelectItem key={folder.name} value={folder.name}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-1">
Limit chat results to documents within a specific folder
</p>
</div>
</div>
<DialogFooter>
<Button onClick={() => setShowChatAdvanced(false)}>Apply</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ChatOptionsDialog;

View File

@ -1,158 +0,0 @@
"use client";
import React from 'react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Settings, ChevronUp, ChevronDown } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { QueryOptions } from '@/components/types';
interface ChatOptionsPanelProps {
showChatAdvanced: boolean;
setShowChatAdvanced: (show: boolean) => void;
queryOptions: QueryOptions;
updateQueryOption: <K extends keyof QueryOptions>(key: K, value: QueryOptions[K]) => void;
availableGraphs: string[];
}
const ChatOptionsPanel: React.FC<ChatOptionsPanelProps> = ({
showChatAdvanced,
setShowChatAdvanced,
queryOptions,
updateQueryOption,
availableGraphs
}) => {
return (
<div>
<button
type="button"
className="flex items-center text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowChatAdvanced(!showChatAdvanced)}
>
<Settings className="mr-1 h-4 w-4" />
Advanced Options
{showChatAdvanced ? <ChevronUp className="ml-1 h-4 w-4" /> : <ChevronDown className="ml-1 h-4 w-4" />}
</button>
{showChatAdvanced && (
<div className="mt-3 p-4 border rounded-md bg-muted">
<div className="space-y-4">
<div>
<Label htmlFor="query-filters" className="block mb-2">Filters (JSON)</Label>
<Textarea
id="query-filters"
value={queryOptions.filters}
onChange={(e) => updateQueryOption('filters', e.target.value)}
placeholder='{"key": "value"}'
rows={3}
/>
</div>
<div>
<Label htmlFor="query-k" className="block mb-2">
Number of Results (k): {queryOptions.k}
</Label>
<Input
id="query-k"
type="number"
min={1}
value={queryOptions.k}
onChange={(e) => updateQueryOption('k', parseInt(e.target.value) || 1)}
/>
</div>
<div>
<Label htmlFor="query-min-score" className="block mb-2">
Minimum Score: {queryOptions.min_score.toFixed(2)}
</Label>
<Input
id="query-min-score"
type="number"
min={0}
max={1}
step={0.01}
value={queryOptions.min_score}
onChange={(e) => updateQueryOption('min_score', parseFloat(e.target.value) || 0)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="query-reranking">Use Reranking</Label>
<Switch
id="query-reranking"
checked={queryOptions.use_reranking}
onCheckedChange={(checked) => updateQueryOption('use_reranking', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="query-colpali">Use Colpali</Label>
<Switch
id="query-colpali"
checked={queryOptions.use_colpali}
onCheckedChange={(checked) => updateQueryOption('use_colpali', checked)}
/>
</div>
<div>
<Label htmlFor="query-max-tokens" className="block mb-2">
Max Tokens: {queryOptions.max_tokens}
</Label>
<Input
id="query-max-tokens"
type="number"
min={1}
max={2048}
value={queryOptions.max_tokens}
onChange={(e) => updateQueryOption('max_tokens', parseInt(e.target.value) || 1)}
/>
</div>
<div>
<Label htmlFor="query-temperature" className="block mb-2">
Temperature: {queryOptions.temperature.toFixed(2)}
</Label>
<Input
id="query-temperature"
type="number"
min={0}
max={2}
step={0.01}
value={queryOptions.temperature}
onChange={(e) => updateQueryOption('temperature', parseFloat(e.target.value) || 0)}
/>
</div>
<div>
<Label htmlFor="graphName" className="block mb-2">Knowledge Graph</Label>
<Select
value={queryOptions.graph_name || "__none__"}
onValueChange={(value) => updateQueryOption('graph_name', value === "__none__" ? undefined : value)}
>
<SelectTrigger className="w-full" id="graphName">
<SelectValue placeholder="Select a knowledge graph" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None (Standard RAG)</SelectItem>
{availableGraphs.map(graphName => (
<SelectItem key={graphName} value={graphName}>
{graphName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Select a knowledge graph to enhance your query with structured relationships
</p>
</div>
</div>
</div>
)}
</div>
);
};
export default ChatOptionsPanel;

View File

@ -1,368 +1,475 @@
"use client";
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { MessageSquare } from 'lucide-react';
import { showAlert } from '@/components/ui/alert-system';
import ChatOptionsDialog from './ChatOptionsDialog';
import ChatMessageComponent from './ChatMessage';
import { useMorphikChat } from '@/hooks/useMorphikChat';
import { ChatMessage, Folder } from '@/components/types';
import { generateUUID } from '@/lib/utils';
import { ChatMessage, QueryOptions, Folder, Source } from '@/components/types';
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;
}
const ChatSection: React.FC<ChatSectionProps> = ({ apiBaseUrl, authToken }) => {
const [chatQuery, setChatQuery] = useState('');
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [showChatAdvanced, setShowChatAdvanced] = useState(false);
/**
* 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[]>([]);
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filters: '{}',
k: 4,
min_score: 0,
use_reranking: false,
use_colpali: true,
max_tokens: 500,
temperature: 0.7
});
// Handle URL parameters for folder and filters
useEffect(() => {
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const folderParam = params.get('folder');
const filtersParam = params.get('filters');
const documentIdsParam = params.get('document_ids');
let shouldShowChatOptions = false;
// Update folder if provided
if (folderParam) {
try {
const folderName = decodeURIComponent(folderParam);
if (folderName) {
console.log(`Setting folder from URL parameter: ${folderName}`);
updateQueryOption('folder_name', folderName);
shouldShowChatOptions = true;
}
} catch (error) {
console.error('Error parsing folder parameter:', error);
}
}
// Handle document_ids (selected documents) parameter - for backward compatibility
if (documentIdsParam) {
try {
const documentIdsJson = decodeURIComponent(documentIdsParam);
const documentIds = JSON.parse(documentIdsJson);
// Create a filter object with external_id filter (correct field name)
const filtersObj = { external_id: documentIds };
const validFiltersJson = JSON.stringify(filtersObj);
console.log(`Setting document_ids filter from URL parameter:`, filtersObj);
updateQueryOption('filters', validFiltersJson);
shouldShowChatOptions = true;
} catch (error) {
console.error('Error parsing document_ids parameter:', error);
}
}
// Handle general filters parameter
if (filtersParam) {
try {
const filtersJson = decodeURIComponent(filtersParam);
// Parse the JSON to confirm it's valid
const filtersObj = JSON.parse(filtersJson);
console.log(`Setting filters from URL parameter:`, filtersObj);
// Store the filters directly as a JSON string
updateQueryOption('filters', filtersJson);
shouldShowChatOptions = true;
// Log a more helpful message about what's happening
if (filtersObj.external_id) {
console.log(`Chat will filter by ${Array.isArray(filtersObj.external_id) ? filtersObj.external_id.length : 1} document(s)`);
}
} catch (error) {
console.error('Error parsing filters parameter:', error);
}
}
// Only show the chat options panel on initial parameter load
if (shouldShowChatOptions) {
setShowChatAdvanced(true);
// Clear URL parameters after processing them to prevent modal from re-appearing on refresh
if (window.history.replaceState) {
const newUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, document.title, newUrl);
}
}
}
}, []);
// Update query options
const updateQueryOption = <K extends keyof QueryOptions>(key: K, value: QueryOptions[K]) => {
setQueryOptions(prev => ({
...prev,
[key]: value
}));
};
// 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: {
'Authorization': authToken ? `Bearer ${authToken}` : ''
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
}
});
if (!response.ok) {
throw new Error(`Failed to fetch graphs: ${response.statusText}`);
throw new Error(`Failed to fetch graphs: ${response.status} ${response.statusText}`);
}
const graphsData = await response.json();
setAvailableGraphs(graphsData.map((graph: { name: string }) => graph.name));
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 graphs and folders when auth token or API URL changes
useEffect(() => {
if (authToken) {
console.log('ChatSection: Fetching data with new auth token');
// Clear current messages when auth changes
setChatMessages([]);
fetchGraphs();
// Fetch available folders for dropdown
const fetchFolders = async () => {
try {
const response = await fetch(`${apiBaseUrl}/folders`, {
headers: {
'Authorization': authToken ? `Bearer ${authToken}` : ''
}
});
if (!response.ok) {
throw new Error(`Failed to fetch folders: ${response.statusText}`);
}
const foldersData = await response.json();
setFolders(foldersData);
} catch (err) {
console.error('Error fetching folders:', err);
}
};
fetchFolders();
}
}, [authToken, apiBaseUrl, fetchGraphs]);
// Handle chat
const handleChat = async () => {
if (!chatQuery.trim()) {
showAlert('Please enter a message', {
type: 'error',
duration: 3000
});
return;
}
// Fetch folders
const fetchFolders = useCallback(async () => {
if (!apiBaseUrl) return;
setLoadingFolders(true);
try {
setLoading(true);
// Add user message to chat
const userMessage: ChatMessage = { role: 'user', content: chatQuery };
setChatMessages(prev => [...prev, userMessage]);
// Prepare options with graph_name and folder_name if they exist
const options = {
filters: JSON.parse(queryOptions.filters || '{}'),
k: queryOptions.k,
min_score: queryOptions.min_score,
use_reranking: queryOptions.use_reranking,
use_colpali: queryOptions.use_colpali,
max_tokens: queryOptions.max_tokens,
temperature: queryOptions.temperature,
graph_name: queryOptions.graph_name,
folder_name: queryOptions.folder_name
};
const response = await fetch(`${apiBaseUrl}/query`, {
method: 'POST',
console.log(`Fetching folders from: ${apiBaseUrl}/folders`);
const response = await fetch(`${apiBaseUrl}/folders`, {
headers: {
'Authorization': authToken ? `Bearer ${authToken}` : '',
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: chatQuery,
...options
})
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
}
});
if (!response.ok) {
throw new Error(`Query failed: ${response.statusText}`);
throw new Error(`Failed to fetch folders: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const foldersData = await response.json();
console.log('Folders data received:', foldersData);
// Add assistant response to chat
const assistantMessage: ChatMessage = {
role: 'assistant',
content: data.completion,
sources: data.sources
};
setChatMessages(prev => [...prev, assistantMessage]);
// If sources are available, retrieve the full source content
if (data.sources && data.sources.length > 0) {
try {
// Fetch full source details
const sourcesResponse = await fetch(`${apiBaseUrl}/batch/chunks`, {
method: 'POST',
headers: {
'Authorization': authToken ? `Bearer ${authToken}` : '',
'Content-Type': 'application/json'
},
body: JSON.stringify({
sources: data.sources,
folder_name: queryOptions.folder_name,
use_colpali: true,
})
});
if (sourcesResponse.ok) {
const sourcesData = await sourcesResponse.json();
// Check if we have any image sources
const imageSources = sourcesData.filter((source: Source) =>
source.content_type?.startsWith('image/') ||
(source.content && (
source.content.startsWith('data:image/png;base64,') ||
source.content.startsWith('data:image/jpeg;base64,')
))
);
console.log('Image sources found:', imageSources.length);
// Update the message with detailed source information
const updatedMessage = {
...assistantMessage,
sources: sourcesData.map((source: Source) => {
return {
document_id: source.document_id,
chunk_number: source.chunk_number,
score: source.score,
content: source.content,
content_type: source.content_type || 'text/plain',
filename: source.filename,
metadata: source.metadata,
download_url: source.download_url
};
})
};
// Update the message with detailed sources
setChatMessages(prev => prev.map((msg, idx) =>
idx === prev.length - 1 ? updatedMessage : msg
));
}
} catch (err) {
console.error('Error fetching source details:', err);
// Continue with basic sources if detailed fetch fails
}
if (Array.isArray(foldersData)) {
setFolders(foldersData);
} else {
console.error('Expected array for folders data but received:', typeof foldersData);
}
setChatQuery(''); // Clear input
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
showAlert(errorMsg, {
type: 'error',
title: 'Chat Query Failed',
duration: 5000
});
console.error('Error fetching folders:', err);
} finally {
setLoading(false);
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 (
<Card className="h-full flex flex-col">
<CardHeader>
<CardTitle>Chat with Your Documents</CardTitle>
<CardDescription>
Ask questions about your documents and get AI-powered answers.
</CardDescription>
</CardHeader>
<CardContent className="flex-grow overflow-hidden flex flex-col">
<ScrollArea className="flex-grow pr-4 mb-4">
{chatMessages.length > 0 ? (
<div className="space-y-4">
{chatMessages.map((message, index) => (
<ChatMessageComponent
key={index}
role={message.role}
content={message.content}
sources={message.sources}
/>
))}
</div>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<MessageSquare className="mx-auto h-12 w-12 mb-2" />
<p>Start a conversation about your documents</p>
<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>
)}
</ScrollArea>
<div className="pt-4 border-t">
<div className="space-y-4">
<div className="flex gap-2">
<Textarea
placeholder="Ask a question..."
value={chatQuery}
onChange={(e) => setChatQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleChat();
}
}}
className="min-h-10"
<div className="flex flex-col pt-4 pb-[80px] md:pb-[120px]">
{messages.map((message) => (
<PreviewMessage
key={message.id}
message={message}
/>
<Button onClick={handleChat} disabled={loading}>
{loading ? 'Sending...' : 'Send'}
</Button>
</div>
))}
<div className="flex justify-between items-center mt-2">
<p className="text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for a new line
</p>
<ChatOptionsDialog
showChatAdvanced={showChatAdvanced}
setShowChatAdvanced={setShowChatAdvanced}
queryOptions={queryOptions}
updateQueryOption={updateQueryOption}
availableGraphs={availableGraphs}
folders={folders}
/>
</div>
{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>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,95 @@
import React from 'react';
export type IconProps = React.HTMLAttributes<SVGElement>;
export function Spin({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`size-4 ${className}`}
{...props}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
);
}
export function ArrowUp({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`size-4 ${className}`}
{...props}
>
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
</svg>
);
}
export function PaperClip({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`size-4 ${className}`}
{...props}
>
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
);
}
export function Stop({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`size-4 ${className}`}
{...props}
>
<rect width="16" height="16" x="4" y="4" />
</svg>
);
}
export function Settings({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`size-4 ${className}`}
{...props}
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}

View File

@ -0,0 +1,3 @@
export { default as ChatSection } from './ChatSection';
export { PreviewMessage, ThinkingMessage } from './ChatMessages';
export * from './icons';

View File

@ -1,7 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Upload } from 'lucide-react';
import { showAlert, removeAlert } from '@/components/ui/alert-system';
import DocumentList from './DocumentList';
@ -135,300 +134,195 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
// Fetch all documents, optionally filtered by folder
const fetchDocuments = useCallback(async (source: string = 'unknown') => {
console.log(`fetchDocuments called from: ${source}`)
try {
// Only set loading state for initial load, not for refreshes
if (documents.length === 0) {
setLoading(true);
}
// Don't fetch if no folder is selected (showing folder grid view)
if (selectedFolder === null) {
setDocuments([]);
console.log(`fetchDocuments called from: ${source}, selectedFolder: ${selectedFolder}`);
// Ensure API URL is valid before proceeding
if (!effectiveApiUrl) {
console.error('fetchDocuments: No valid API URL available.');
setLoading(false);
return;
}
}
// Prepare for document fetching
let apiUrl = `${effectiveApiUrl}/documents`;
// CRITICAL FIX: The /documents endpoint uses POST method
let method = 'POST';
let requestBody = {};
// Immediately clear documents and set loading state if selectedFolder is null (folder grid view)
if (selectedFolder === null) {
console.log('fetchDocuments: No folder selected, clearing documents.');
setDocuments([]);
setLoading(false);
return;
}
// If we're looking at a specific folder (not "all" documents)
if (selectedFolder && selectedFolder !== "all") {
console.log(`Fetching documents for folder: ${selectedFolder}`);
// Set loading state only for initial load or when explicitly changing folders
if (documents.length === 0 || source === 'folders loaded or selectedFolder changed') {
setLoading(true);
}
// Find the target folder to get its document IDs
try {
let documentsToFetch: Document[] = [];
if (selectedFolder === "all") {
// Fetch all documents for the "all" view
console.log('fetchDocuments: Fetching all documents');
const response = await fetch(`${effectiveApiUrl}/documents`, {
method: 'POST', // Assuming POST is correct for fetching all
headers: {
'Content-Type': 'application/json',
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
},
body: JSON.stringify({}) // Empty body for all documents
});
if (!response.ok) {
throw new Error(`Failed to fetch all documents: ${response.statusText}`);
}
documentsToFetch = await response.json();
console.log(`fetchDocuments: Fetched ${documentsToFetch.length} total documents`);
} else {
// Fetch documents for a specific folder
console.log(`fetchDocuments: Fetching documents for folder: ${selectedFolder}`);
const targetFolder = folders.find(folder => folder.name === selectedFolder);
if (targetFolder) {
// Ensure document_ids is always an array
const documentIds = Array.isArray(targetFolder.document_ids) ? targetFolder.document_ids : [];
if (documentIds.length > 0) {
// If we found the folder and it contains documents,
// Get document details for each document ID in the folder
console.log(`Found folder ${targetFolder.name} with ${documentIds.length} documents`);
// Use batch/documents endpoint which accepts document_ids for efficient fetching
apiUrl = `${effectiveApiUrl}/batch/documents`;
method = 'POST';
requestBody = {
document_ids: [...documentIds]
};
} else {
console.log(`Folder ${targetFolder.name} has no documents`);
// For empty folder, we'll send an empty request body to the documents endpoint
requestBody = {};
if (targetFolder && Array.isArray(targetFolder.document_ids) && targetFolder.document_ids.length > 0) {
// Folder found and has documents, fetch them by ID
console.log(`fetchDocuments: Folder found with ${targetFolder.document_ids.length} IDs. Fetching details...`);
const response = await fetch(`${effectiveApiUrl}/batch/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
},
body: JSON.stringify({ document_ids: targetFolder.document_ids })
});
if (!response.ok) {
throw new Error(`Failed to fetch batch documents: ${response.statusText}`);
}
documentsToFetch = await response.json();
console.log(`fetchDocuments: Fetched details for ${documentsToFetch.length} documents`);
} else {
console.log(`Folder ${selectedFolder} has no documents or couldn't be found`);
// For unknown folder, we'll send an empty request body to the documents endpoint
requestBody = {};
// Folder not found, or folder is empty
if (targetFolder) {
console.log(`fetchDocuments: Folder ${selectedFolder} found but is empty.`);
} else {
console.log(`fetchDocuments: Folder ${selectedFolder} not found in current state.`);
}
// In either case, the folder contains no documents to display
documentsToFetch = [];
}
} else {
// For "all" documents request
requestBody = {};
}
console.log(`DocumentsSection: Sending ${method} request to: ${apiUrl}`);
// Use non-blocking fetch with appropriate method
fetch(apiUrl, {
method: method,
headers: {
'Content-Type': 'application/json',
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
},
body: JSON.stringify(requestBody)
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch documents: ${response.statusText}`);
// Process fetched documents (add status if needed)
const processedData = documentsToFetch.map((doc: Document) => {
if (!doc.system_metadata) {
doc.system_metadata = {};
}
return response.json();
})
.then((data: Document[]) => {
// Ensure all documents have a valid status in system_metadata
const processedData = data.map((doc: Document) => {
// If system_metadata doesn't exist, create it
if (!doc.system_metadata) {
doc.system_metadata = {};
}
// If status is missing and we have a newly uploaded document, it should be "processing"
if (!doc.system_metadata.status && doc.system_metadata.folder_name) {
doc.system_metadata.status = "processing";
}
return doc;
});
console.log(`Fetched ${processedData.length} documents from ${apiUrl}`);
// Only update state if component is still mounted
setDocuments(processedData);
setLoading(false);
// Log for debugging
const processingCount = processedData.filter(doc => doc.system_metadata?.status === "processing").length;
if (processingCount > 0) {
console.log(`Found ${processingCount} documents still processing`);
if (!doc.system_metadata.status && doc.system_metadata.folder_name) {
doc.system_metadata.status = "processing";
}
})
.catch(err => {
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
console.error(`Document fetch error: ${errorMsg}`);
showAlert(errorMsg, {
type: 'error',
title: 'Error',
duration: 5000
});
setLoading(false);
return doc;
});
// Update state
setDocuments(processedData);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
console.error(`Error in fetchDocuments (${source}): ${errorMsg}`);
showAlert(errorMsg, {
type: 'error',
title: 'Error Fetching Documents',
duration: 5000
});
// Clear documents on error to avoid showing stale/incorrect data
setDocuments([]);
} finally {
// Always ensure loading state is turned off
setLoading(false);
}
// Dependencies: URL, auth, selected folder, and the folder list itself
}, [effectiveApiUrl, authToken, selectedFolder, folders, documents.length]);
// Fetch all folders
const fetchFolders = useCallback(async () => {
console.log('fetchFolders called');
setFoldersLoading(true);
try {
const response = await fetch(`${effectiveApiUrl}/folders`, {
method: 'GET',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
}
});
if (!response.ok) {
throw new Error(`Failed to fetch folders: ${response.statusText}`);
}
const data = await response.json();
console.log(`Fetched ${data.length} folders`);
setFolders(data);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
console.error(`Folder fetch error: ${errorMsg}`);
showAlert(errorMsg, {
type: 'error',
title: 'Error',
duration: 5000
});
setLoading(false);
}
}, [effectiveApiUrl, authToken, documents.length, selectedFolder, folders]);
// Fetch all folders
const fetchFolders = useCallback(async (source: string = 'unknown') => {
console.log(`fetchFolders called from: ${source}`)
try {
setFoldersLoading(true);
// Use non-blocking fetch with GET method
const url = `${effectiveApiUrl}/folders`;
console.log(`Fetching folders from: ${url}`);
fetch(url, {
method: 'GET',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
}
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch folders: ${response.statusText}`);
}
return response.json();
})
.then(data => {
console.log(`Fetched ${data.length} folders`);
setFolders(data);
setFoldersLoading(false);
})
.catch(err => {
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
console.error(`Error fetching folders: ${errorMsg}`);
showAlert(`Error fetching folders: ${errorMsg}`, {
type: 'error',
duration: 3000
});
setFoldersLoading(false);
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
console.error(`Error in fetchFolders: ${errorMsg}`);
} finally {
setFoldersLoading(false);
}
}, [effectiveApiUrl, authToken]);
// No automatic polling - we'll only refresh on upload and manual refresh button clicks
// Function to refresh documents based on current folder state
const refreshDocuments = useCallback(async () => {
await fetchDocuments('refreshDocuments call');
}, [fetchDocuments]);
// Create memoized fetch functions that don't cause infinite loops
// We need to make sure they don't trigger re-renders that cause further fetches
const stableFetchFolders = useCallback((source: string = 'stable-call') => {
console.log(`stableFetchFolders called from: ${source}`);
return fetchFolders(source);
// Keep dependencies minimal to prevent recreation on every render
}, [effectiveApiUrl, authToken]);
const stableFetchDocuments = useCallback((source: string = 'stable-call') => {
console.log(`stableFetchDocuments called from: ${source}`);
return fetchDocuments(source);
// Keep dependencies minimal to prevent recreation on every render
}, [effectiveApiUrl, authToken, selectedFolder]);
// Fetch data when auth token or API URL changes
// Fetch folders initially
useEffect(() => {
// Only run this effect if we have auth or are on localhost
if (authToken || effectiveApiUrl.includes('localhost')) {
console.log('DocumentsSection: Fetching initial data');
console.log('DocumentsSection: Initial folder fetch');
fetchFolders();
}, [fetchFolders]);
// Clear current data and reset state
setDocuments([]);
setFolders([]);
setSelectedDocument(null);
setSelectedDocuments([]);
// Fetch documents when folders are loaded or selectedFolder changes
useEffect(() => {
if (!foldersLoading && folders.length > 0) {
// Avoid fetching documents on initial mount if selectedFolder is null
// unless initialFolder was specified
if (isInitialMount.current && selectedFolder === null && !initialFolder) {
console.log('Initial mount with no folder selected, skipping document fetch');
isInitialMount.current = false;
return;
}
console.log('Folders loaded or selectedFolder changed, fetching documents...', selectedFolder);
fetchDocuments('folders loaded or selectedFolder changed');
isInitialMount.current = false; // Mark initial mount as complete
} else if (!foldersLoading && folders.length === 0 && selectedFolder === null) {
// Handle case where there are no folders at all
console.log('No folders found, clearing documents and stopping loading.');
setDocuments([]);
setLoading(false);
isInitialMount.current = false;
}
}, [foldersLoading, folders, selectedFolder, fetchDocuments, initialFolder]);
// Use a flag to track component mounting state
let isMounted = true;
// Poll for document status if any document is processing
useEffect(() => {
const hasProcessing = documents.some(
(doc) => doc.system_metadata?.status === 'processing'
);
// Create an abort controller for request cancellation
const controller = new AbortController();
if (hasProcessing) {
console.log('Polling for document status...');
const intervalId = setInterval(() => {
console.log('Polling interval: calling refreshDocuments');
refreshDocuments(); // Fetch documents again to check status
}, 5000); // Poll every 5 seconds
// Add a slight delay to prevent multiple rapid calls
const timeoutId = setTimeout(() => {
if (isMounted) {
// Fetch folders first
stableFetchFolders('initial-load')
.then(() => {
// Only fetch documents if we're still mounted
if (isMounted && selectedFolder !== null) {
return stableFetchDocuments('initial-load');
}
})
.catch(err => {
console.error("Error during initial data fetch:", err);
});
}
}, 100);
// Cleanup when component unmounts or the effect runs again
// Cleanup function to clear the interval when the component unmounts
// or when there are no more processing documents
return () => {
clearTimeout(timeoutId);
isMounted = false;
controller.abort();
console.log('Clearing polling interval');
clearInterval(intervalId);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authToken, effectiveApiUrl, stableFetchFolders, stableFetchDocuments, selectedFolder]);
// Helper function to refresh documents based on current view
const refreshDocuments = async (folders: Folder[]) => {
try {
if (selectedFolder && selectedFolder !== "all") {
// Find the folder by name
const targetFolder = folders.find(folder => folder.name === selectedFolder);
if (targetFolder) {
console.log(`Refresh: Found folder ${targetFolder.name} in fresh data`);
// Get the document IDs from the folder
const documentIds = Array.isArray(targetFolder.document_ids) ? targetFolder.document_ids : [];
console.log(`Refresh: Folder has ${documentIds.length} documents`);
if (documentIds.length > 0) {
// Fetch document details for the IDs
const docResponse = await fetch(`${effectiveApiUrl}/batch/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
},
body: JSON.stringify({
document_ids: [...documentIds]
})
});
if (!docResponse.ok) {
throw new Error(`Failed to fetch documents: ${docResponse.statusText}`);
}
const freshDocs = await docResponse.json();
console.log(`Refresh: Fetched ${freshDocs.length} document details`);
// Update documents state
setDocuments(freshDocs);
} else {
// Empty folder
setDocuments([]);
}
} else {
console.log(`Refresh: Selected folder ${selectedFolder} not found in fresh data`);
setDocuments([]);
}
} else if (selectedFolder === "all") {
// For "all" documents view, fetch all documents
const allDocsResponse = await fetch(`${effectiveApiUrl}/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
},
body: JSON.stringify({})
});
if (!allDocsResponse.ok) {
throw new Error(`Failed to fetch all documents: ${allDocsResponse.statusText}`);
}
const allDocs = await allDocsResponse.json();
console.log(`Refresh: Fetched ${allDocs.length} documents for "all" view`);
setDocuments(allDocs);
}
} catch (error) {
console.error("Error refreshing documents:", error);
}
};
}, [documents, refreshDocuments]);
// Collapse sidebar when a folder is selected
useEffect(() => {
@ -439,89 +333,6 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
}
}, [selectedFolder, setSidebarCollapsed]);
// Fetch documents when selected folder changes
useEffect(() => {
// Skip initial render to prevent double fetching with the auth useEffect
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
console.log(`Folder selection changed to: ${selectedFolder}`);
// Clear selected document when changing folders
setSelectedDocument(null);
setSelectedDocuments([]);
// CRITICAL: Clear document list immediately to prevent showing old documents
// This prevents showing documents from previous folders while loading
setDocuments([]);
// Create a flag to handle component unmounting
let isMounted = true;
// Create an abort controller for fetch operations
const controller = new AbortController();
// Only fetch if we have a valid auth token or running locally
if ((authToken || effectiveApiUrl.includes('localhost')) && isMounted) {
// Add a small delay to prevent rapid consecutive calls
const timeoutId = setTimeout(async () => {
try {
// Set loading state to show we're fetching new data
setLoading(true);
// Start with a fresh folder fetch
const folderResponse = await fetch(`${effectiveApiUrl}/folders`, {
method: 'GET',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
},
signal: controller.signal
});
if (!folderResponse.ok) {
throw new Error(`Failed to fetch folders: ${folderResponse.statusText}`);
}
// Get fresh folder data
const freshFolders = await folderResponse.json();
console.log(`Folder change: Fetched ${freshFolders.length} folders with fresh data`);
// Update folder state
if (isMounted) {
setFolders(freshFolders);
// Then fetch documents with fresh folder data
await refreshDocuments(freshFolders);
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error("Error during folder change fetch:", err);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
}, 100);
// Clean up timeout if unmounted
return () => {
clearTimeout(timeoutId);
isMounted = false;
controller.abort();
};
}
// Cleanup function to prevent updates after unmount
return () => {
isMounted = false;
controller.abort();
};
// Only depend on these specific props to prevent infinite loops
}, [selectedFolder, authToken, effectiveApiUrl]);
// Fetch a specific document by ID
const fetchDocument = async (documentId: string) => {
try {
@ -797,35 +608,13 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
}
// Force a fresh refresh after upload
// This is a special function to ensure we get truly fresh data
const refreshAfterUpload = async () => {
try {
console.log("Performing fresh refresh after upload");
// Clear folder data to force a clean refresh
setFolders([]);
// Get fresh folder data from the server
const folderResponse = await fetch(`${effectiveApiUrl}/folders`, {
method: 'GET',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
}
});
if (!folderResponse.ok) {
throw new Error(`Failed to fetch folders: ${folderResponse.statusText}`);
}
const freshFolders = await folderResponse.json();
console.log(`Upload: Fetched ${freshFolders.length} folders with fresh data`);
// Update folders state with fresh data
setFolders(freshFolders);
// Now fetch documents based on the current view
await refreshDocuments(freshFolders);
console.log("Performing fresh refresh after upload (file)");
// ONLY fetch folders. The useEffect watching folders will trigger fetchDocuments.
await fetchFolders();
} catch (err) {
console.error('Error refreshing after upload:', err);
console.error('Error refreshing after file upload:', err);
}
};
@ -953,35 +742,13 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
}
// Force a fresh refresh after upload
// This is a special function to ensure we get truly fresh data
const refreshAfterUpload = async () => {
try {
console.log("Performing fresh refresh after upload");
// Clear folder data to force a clean refresh
setFolders([]);
// Get fresh folder data from the server
const folderResponse = await fetch(`${effectiveApiUrl}/folders`, {
method: 'GET',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
}
});
if (!folderResponse.ok) {
throw new Error(`Failed to fetch folders: ${folderResponse.statusText}`);
}
const freshFolders = await folderResponse.json();
console.log(`Upload: Fetched ${freshFolders.length} folders with fresh data`);
// Update folders state with fresh data
setFolders(freshFolders);
// Now fetch documents based on the current view
await refreshDocuments(freshFolders);
console.log("Performing fresh refresh after upload (batch)");
// ONLY fetch folders. The useEffect watching folders will trigger fetchDocuments.
await fetchFolders();
} catch (err) {
console.error('Error refreshing after upload:', err);
console.error('Error refreshing after batch upload:', err);
}
};
@ -1113,35 +880,13 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
}
// Force a fresh refresh after upload
// This is a special function to ensure we get truly fresh data
const refreshAfterUpload = async () => {
try {
console.log("Performing fresh refresh after upload");
// Clear folder data to force a clean refresh
setFolders([]);
// Get fresh folder data from the server
const folderResponse = await fetch(`${effectiveApiUrl}/folders`, {
method: 'GET',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
}
});
if (!folderResponse.ok) {
throw new Error(`Failed to fetch folders: ${folderResponse.statusText}`);
}
const freshFolders = await folderResponse.json();
console.log(`Upload: Fetched ${freshFolders.length} folders with fresh data`);
// Update folders state with fresh data
setFolders(freshFolders);
// Now fetch documents based on the current view
await refreshDocuments(freshFolders);
console.log("Performing fresh refresh after upload (text)");
// ONLY fetch folders. The useEffect watching folders will trigger fetchDocuments.
await fetchFolders();
} catch (err) {
console.error('Error refreshing after upload:', err);
console.error('Error refreshing after text upload:', err);
}
};
@ -1190,54 +935,32 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
// Function to trigger refresh
const handleRefresh = () => {
console.log("Manual refresh triggered");
// Show a loading indicator
showAlert("Refreshing documents and folders...", {
type: 'info',
duration: 1500
});
// First clear folder data to force a clean refresh
setLoading(true);
setFolders([]);
// Create a new function to perform a truly fresh fetch
const performFreshFetch = async () => {
try {
// First get fresh folder data from the server
const folderResponse = await fetch(`${effectiveApiUrl}/folders`, {
method: 'GET',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
}
});
// ONLY fetch folders. The useEffect watching folders will trigger fetchDocuments.
await fetchFolders();
if (!folderResponse.ok) {
throw new Error(`Failed to fetch folders: ${folderResponse.statusText}`);
}
// Get the fresh folder data
const freshFolders = await folderResponse.json();
console.log(`Refresh: Fetched ${freshFolders.length} folders with fresh data`);
// Update folders state with fresh data
setFolders(freshFolders);
// Use our helper function to refresh documents with fresh folder data
await refreshDocuments(freshFolders);
// Show success message
showAlert("Refresh completed successfully", {
// Show success message (consider moving this if fetchFolders doesn't guarantee documents are loaded)
showAlert("Refresh initiated. Data will update shortly.", {
type: 'success',
duration: 1500
});
} catch (error) {
console.error("Error during refresh:", error);
console.error("Error during refresh fetchFolders:", error);
showAlert(`Error refreshing: ${error instanceof Error ? error.message : 'Unknown error'}`, {
type: 'error',
duration: 3000
});
} finally {
setLoading(false);
// setLoading(false); // Loading will be handled by fetchDocuments triggered by useEffect
}
};
@ -1265,40 +988,8 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
</div>
</div>
)}
{/* Hide the main header when viewing a specific folder - it will be merged with the FolderList header */}
{selectedFolder === null && (
<div className="flex justify-between items-center py-3 mb-4">
<div>
<h2 className="text-2xl font-bold leading-tight">Folders</h2>
<p className="text-muted-foreground">Manage your uploaded documents and view their metadata.</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleRefresh}
disabled={loading}
className="flex items-center"
title="Refresh folders"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
<path d="M8 16H3v5"></path>
</svg>
Refresh
</Button>
<UploadDialog
showUploadDialog={showUploadDialog}
setShowUploadDialog={setShowUploadDialog}
loading={loading}
onFileUpload={handleFileUpload}
onBatchFileUpload={handleBatchFileUpload}
onTextUpload={handleTextUpload}
/>
</div>
</div>
)}
{/* Folder view controls - only show when not in a specific folder */}
{/* No longer needed - controls will be provided in FolderList */}
{/* Render the FolderList with header at all times when selectedFolder is not null */}
{selectedFolder !== null && (

View File

@ -209,7 +209,6 @@ const FolderList: React.FC<FolderListProps> = ({
return (
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="font-medium text-lg">Folders</h2>
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
@ -230,8 +229,7 @@ const FolderList: React.FC<FolderListProps> = ({
id="folderName"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="My Folder"
className="mt-1"
placeholder="Enter folder name"
/>
</div>
<div>
@ -240,22 +238,39 @@ const FolderList: React.FC<FolderListProps> = ({
id="folderDescription"
value={newFolderDescription}
onChange={(e) => setNewFolderDescription(e.target.value)}
placeholder="Enter a description for this folder"
className="mt-1"
placeholder="Enter folder description"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewFolderDialog(false)}>
Cancel
</Button>
<Button onClick={handleCreateFolder} disabled={isCreatingFolder || !newFolderName.trim()}>
<Button variant="ghost" onClick={() => setShowNewFolderDialog(false)} disabled={isCreatingFolder}>Cancel</Button>
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim() || isCreatingFolder}>
{isCreatingFolder ? 'Creating...' : 'Create Folder'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex items-center gap-2">
{refreshAction && (
<Button
variant="outline"
onClick={refreshAction}
className="flex items-center"
title="Refresh folders"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
<path d="M8 16H3v5"></path>
</svg>
Refresh
</Button>
)}
{uploadDialogComponent}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6 py-2">
@ -263,7 +278,7 @@ const FolderList: React.FC<FolderListProps> = ({
className="cursor-pointer group flex flex-col items-center"
onClick={() => updateSelectedFolder("all")}
>
<div className="mb-2 group-hover:scale-110 transition-transform">
<div className="h-16 w-16 flex items-center justify-center mb-2 group-hover:scale-110 transition-transform">
<span className="text-4xl" aria-hidden="true">📄</span>
</div>
<span className="text-sm font-medium text-center group-hover:text-primary transition-colors">All Documents</span>
@ -275,8 +290,8 @@ const FolderList: React.FC<FolderListProps> = ({
className="cursor-pointer group flex flex-col items-center"
onClick={() => updateSelectedFolder(folder.name)}
>
<div className="mb-2 group-hover:scale-110 transition-transform">
<Image src="/icons/folder-icon.png" alt="Folder" width={64} height={64} />
<div className="h-16 w-16 flex items-center justify-center mb-2 group-hover:scale-110 transition-transform">
<Image src="/icons/folder-icon.png" alt="Folder" width={64} height={64} className="object-contain" />
</div>
<span className="text-sm font-medium truncate text-center w-full max-w-[100px] group-hover:text-primary transition-colors">{folder.name}</span>
</div>

View File

@ -1,7 +1,6 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
@ -122,14 +121,8 @@ const SearchSection: React.FC<SearchSectionProps> = ({ apiBaseUrl, authToken })
};
return (
<Card className="flex-1 flex flex-col h-full">
<CardHeader>
<CardTitle>Search Documents</CardTitle>
<CardDescription>
Search across your documents to find relevant information.
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
<div className="flex-1 flex flex-col h-full p-4">
<div className="flex-1 flex flex-col">
<div className="space-y-4">
<div className="flex gap-2">
<Input
@ -179,8 +172,8 @@ const SearchSection: React.FC<SearchSectionProps> = ({ apiBaseUrl, authToken })
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,222 @@
import { useState, useCallback } from 'react';
import type { ChatMessage as MorphikChatMessage, QueryOptions } from '@/components/types';
import { showAlert } from '@/components/ui/alert-system';
import { generateUUID } from '@/lib/utils';
import { UIMessage } from '@/components/chat/ChatMessages';
// Define a simple Attachment type for our purposes
interface Attachment {
url: string;
name: string;
contentType: string;
}
// Map your ChatMessage/Source to UIMessage
function mapMorphikToUIMessage(msg: MorphikChatMessage): UIMessage {
return {
id: generateUUID(),
role: msg.role,
content: msg.content,
createdAt: new Date(),
...(msg.sources && { experimental_customData: { sources: msg.sources } }),
};
}
export function useMorphikChat(chatId: string, apiBaseUrl: string, authToken: string | null, initialMessages: MorphikChatMessage[] = []) {
const [messages, setMessages] = useState<UIMessage[]>(initialMessages.map(mapMorphikToUIMessage));
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
// Query options state (moved from ChatSection)
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filters: '{}',
k: 4,
min_score: 0,
use_reranking: false,
use_colpali: true,
max_tokens: 500,
temperature: 0.7,
});
// Mapping to useChat's status
const status = isLoading ? 'streaming' : 'ready';
const updateQueryOption = <K extends keyof QueryOptions>(key: K, value: QueryOptions[K]) => {
setQueryOptions(prev => ({ ...prev, [key]: value }));
};
const append = useCallback(async (message: UIMessage | Omit<UIMessage, 'id'>) => {
// Add user message immediately
const newUserMessage: UIMessage = {
id: generateUUID(),
...message as Omit<UIMessage, 'id'>,
createdAt: new Date()
};
setMessages(prev => [...prev, newUserMessage]);
setIsLoading(true);
// Call your backend
try {
// Prepare options
const options = {
filters: JSON.parse(queryOptions.filters || '{}'),
k: queryOptions.k,
min_score: queryOptions.min_score,
use_reranking: queryOptions.use_reranking,
use_colpali: queryOptions.use_colpali,
max_tokens: queryOptions.max_tokens,
temperature: queryOptions.temperature,
graph_name: queryOptions.graph_name,
folder_name: queryOptions.folder_name,
};
console.log(`Sending to ${apiBaseUrl}/query:`, { query: newUserMessage.content, ...options });
const response = await fetch(`${apiBaseUrl}/query`, {
method: 'POST',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}),
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: newUserMessage.content,
...options
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(errorData.detail || `Query failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('Query response:', data);
// Add assistant response
const assistantMessage: UIMessage = {
id: generateUUID(),
role: 'assistant',
content: data.completion,
experimental_customData: { sources: data.sources },
createdAt: new Date(),
};
setMessages(prev => [...prev, assistantMessage]);
// If sources are available, retrieve the full source content
if (data.sources && data.sources.length > 0) {
try {
// Fetch full source details
console.log(`Fetching sources from ${apiBaseUrl}/batch/chunks`);
const sourcesResponse = await fetch(`${apiBaseUrl}/batch/chunks`, {
method: 'POST',
headers: {
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}),
'Content-Type': 'application/json'
},
body: JSON.stringify({
sources: data.sources,
folder_name: queryOptions.folder_name,
use_colpali: true,
})
});
if (sourcesResponse.ok) {
const sourcesData = await sourcesResponse.json();
console.log('Sources data:', sourcesData);
// Update the assistantMessage with full source content
setMessages(prev => {
const updatedMessages = [...prev];
const lastMessageIndex = updatedMessages.length - 1;
if (lastMessageIndex >= 0 && updatedMessages[lastMessageIndex].role === 'assistant') {
updatedMessages[lastMessageIndex] = {
...updatedMessages[lastMessageIndex],
experimental_customData: {
sources: sourcesData
}
};
}
return updatedMessages;
});
} else {
console.error('Error fetching sources:', sourcesResponse.status, sourcesResponse.statusText);
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
console.error('Error fetching full source content:', errorMsg);
}
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
console.error('Chat query error:', errorMsg);
showAlert(errorMsg, { type: 'error', title: 'Chat Error', duration: 5000 });
// Add an error message
setMessages(prev => [...prev, {
id: generateUUID(),
role: 'assistant',
content: `Error: ${errorMsg}`,
createdAt: new Date()
}]);
} finally {
setIsLoading(false);
}
}, [apiBaseUrl, authToken, queryOptions]);
const handleSubmit = useCallback((e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (!input.trim() && attachments.length === 0) return;
append({
role: 'user',
content: input,
createdAt: new Date(),
});
// Clear input and attachments
setInput('');
setAttachments([]);
}, [input, attachments, append]);
// Add other functions as needed
const reload = useCallback(() => {
// Implement logic to reload last message if needed
if (messages.length >= 2) {
const lastUserMessageIndex = [...messages].reverse().findIndex(m => m.role === 'user');
if (lastUserMessageIndex !== -1) {
const actualIndex = messages.length - 1 - lastUserMessageIndex;
const lastUserMessage = messages[actualIndex];
// Remove last assistant message and submit the last user message again
setMessages(prev => prev.slice(0, prev.length - 1));
append(lastUserMessage);
}
}
}, [messages, append]);
const stop = useCallback(() => {
setIsLoading(false);
// Any additional logic to cancel ongoing requests would go here
}, []);
return {
messages,
setMessages,
input,
setInput,
isLoading,
status, // Map to useChat's status for compatibility
handleSubmit,
append,
reload,
stop,
attachments,
setAttachments,
// Expose query options state and updater
queryOptions,
updateQueryOption,
};
}

View File

@ -1,6 +1,9 @@
import { clsx, type ClassValue } from "clsx"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
/**
* Combines class names using clsx and tailwind-merge
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@ -110,3 +113,49 @@ export function createAuthHeaders(token: string | null, contentType?: string): H
return headers;
}
/**
* Generate a UUID v4 string
* This is a simple implementation for client-side use
*/
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Format date to relative time (e.g. "2 hours ago")
*/
export function formatRelativeTime(date: Date): string {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return 'just now';
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''} ago`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours} hour${diffInHours > 1 ? 's' : ''} ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 30) {
return `${diffInDays} day${diffInDays > 1 ? 's' : ''} ago`;
}
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return `${diffInMonths} month${diffInMonths > 1 ? 's' : ''} ago`;
}
const diffInYears = Math.floor(diffInMonths / 12);
return `${diffInYears} year${diffInYears > 1 ? 's' : ''} ago`;
}

View File

@ -36,6 +36,7 @@
"lint": "next lint"
},
"dependencies": {
"@ai-sdk/react": "^1.2.9",
"@radix-ui/primitive": "^1.1.2",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-checkbox": "^1.1.5",
@ -50,8 +51,10 @@
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.4",
"accessor-fn": "^1.5.3",
"accordion": "^3.0.2",
"ai": "^4.3.10",
"alert": "^6.0.2",
"caniuse-lite": "^1.0.30001714",
"class-variance-authority": "^0.7.1",
@ -76,7 +79,7 @@
},
"devDependencies": {
"@shadcn/ui": "^0.0.4",
"@types/node": "^20",
"@types/node": "^20.17.31",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"eslint": "^8",

View File

@ -18,7 +18,10 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@/components/*": ["./components/*"],
"@/hooks/*": ["./hooks/*"],
"@/lib/*": ["./lib/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],

View File

@ -53,6 +53,7 @@ def should_ignore_directory(dirname: str) -> bool:
"build",
"dist",
"node_modules",
".next",
}
return dirname in ignore_dirs
@ -66,14 +67,15 @@ def get_target_directories(mode: str, root_dir: str) -> Set[str]:
"core": ["core"],
"sdk": ["sdks"],
"test": ["core/tests", "sdks/python/tests"],
"ui-component": ["ee/ui-component"],
}
return {os.path.join(root_dir, d) for d in mode_dirs.get(mode, [])}
def aggregate_python_files(root_dir: str, output_file: str, script_name: str, mode: str = "all") -> None:
def aggregate_files(root_dir: str, output_file: str, script_name: str, mode: str = "all") -> None:
"""
Recursively search through directories and aggregate Python files.
Recursively search through directories and aggregate relevant files based on mode.
Args:
root_dir: Root directory to start search
@ -115,15 +117,21 @@ Root Directory: {root_dir}
rel_path = os.path.relpath(os.path.join(dirpath, d), root_dir)
tree.add_path(rel_path, is_file=False)
# Process Python files
python_files = [
# Determine relevant file extensions based on mode
if mode == "ui-component":
relevant_extensions = (".js", ".jsx", ".ts", ".tsx", ".css", ".html", ".json")
else:
relevant_extensions = (".py",)
# Process relevant files
relevant_files = [
f
for f in filenames
if f.endswith(".py") and f != "__init__.py" and f != script_name and f != output_file
if f.endswith(relevant_extensions) and f != "__init__.py" and f != script_name and f != output_file
]
for py_file in python_files:
file_path = os.path.join(dirpath, py_file)
for file_name in relevant_files:
file_path = os.path.join(dirpath, file_name)
rel_path = os.path.relpath(file_path, root_dir)
# Add file to tree
@ -143,14 +151,20 @@ Root Directory: {root_dir}
for dirpath, dirnames, filenames in os.walk(target_dir, topdown=True):
dirnames[:] = [d for d in dirnames if not should_ignore_directory(d)]
python_files = [
# Determine relevant file extensions based on mode
if mode == "ui-component":
relevant_extensions = (".js", ".jsx", ".ts", ".tsx", ".css", ".html", ".json")
else:
relevant_extensions = (".py",)
relevant_files = [
f
for f in filenames
if f.endswith(".py") and f != "__init__.py" and f != script_name and f != output_file
if f.endswith(relevant_extensions) and f != "__init__.py" and f != script_name and f != output_file
]
for py_file in python_files:
file_path = os.path.join(dirpath, py_file)
for file_name in relevant_files:
file_path = os.path.join(dirpath, file_name)
rel_path = os.path.relpath(file_path, root_dir)
try:
@ -174,7 +188,7 @@ def main():
parser = argparse.ArgumentParser(description="Aggregate Python files with directory structure")
parser.add_argument(
"--mode",
choices=["all", "core", "sdk", "test"],
choices=["all", "core", "sdk", "test", "ui-component"],
default="all",
help="Which directories to process",
)
@ -189,7 +203,7 @@ def main():
print(f"Output: {args.output}")
print(f"Root directory: {current_dir}")
aggregate_python_files(
aggregate_files(
root_dir=current_dir,
output_file=args.output,
script_name=script_name,