mirror of
https://github.com/james-m-jordan/morphik-core.git
synced 2025-05-09 19:32:38 +00:00
Make UI comp tastier (#119)
This commit is contained in:
parent
8079b6d51e
commit
e8b7f6789b
@ -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
@ -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>
|
||||
)}
|
||||
|
@ -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;
|
149
ee/ui-component/components/chat/ChatMessages.tsx
Normal file
149
ee/ui-component/components/chat/ChatMessages.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
95
ee/ui-component/components/chat/icons.tsx
Normal file
95
ee/ui-component/components/chat/icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
ee/ui-component/components/chat/index.ts
Normal file
3
ee/ui-component/components/chat/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as ChatSection } from './ChatSection';
|
||||
export { PreviewMessage, ThinkingMessage } from './ChatMessages';
|
||||
export * from './icons';
|
@ -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 && (
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
15
ee/ui-component/components/ui/skeleton.tsx
Normal file
15
ee/ui-component/components/ui/skeleton.tsx
Normal 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 }
|
222
ee/ui-component/hooks/useMorphikChat.ts
Normal file
222
ee/ui-component/hooks/useMorphikChat.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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`;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -18,7 +18,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"@/components/*": ["./components/*"],
|
||||
"@/hooks/*": ["./hooks/*"],
|
||||
"@/lib/*": ["./lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user