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 {
|
try {
|
||||||
// Dynamic import
|
// Dynamic import
|
||||||
const ForceGraphModule = await import('force-graph');
|
const ForceGraphModule = await import('force-graph');
|
||||||
|
|
||||||
// Get the ForceGraph constructor function
|
|
||||||
const ForceGraphConstructor = ForceGraphModule.default;
|
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
|
// Create a new graph instance using the 'new' keyword
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
graphInstance = new ForceGraphConstructor(containerRef.current) as ForceGraphInstance;
|
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
|
// Always use nodeCanvasObject to have consistent rendering regardless of label visibility
|
||||||
if (graph.nodeCanvasObject) {
|
if (graph.nodeCanvasObject) {
|
||||||
graph.nodeCanvasObject((node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
graph.nodeCanvasObject((node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
// Draw the node circle
|
|
||||||
const nodeR = 5;
|
const nodeR = 5;
|
||||||
|
|
||||||
if (typeof node.x !== 'number' || typeof node.y !== 'number') return;
|
if (typeof node.x !== 'number' || typeof node.y !== 'number') return;
|
||||||
|
|
||||||
const x = node.x;
|
const x = node.x;
|
||||||
const y = node.y;
|
const y = node.y;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, nodeR, 0, 2 * Math.PI);
|
ctx.arc(x, y, nodeR, 0, 2 * Math.PI);
|
||||||
ctx.fillStyle = node.color;
|
ctx.fillStyle = node.color;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Only draw the text label if showNodeLabels is true
|
|
||||||
if (showNodeLabels) {
|
if (showNodeLabels) {
|
||||||
const label = node.label;
|
const label = node.label;
|
||||||
const fontSize = 12/globalScale;
|
const fontSize = 12/globalScale;
|
||||||
|
|
||||||
ctx.font = `${fontSize}px Sans-Serif`;
|
ctx.font = `${fontSize}px Sans-Serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
// Add a background for better readability
|
|
||||||
const textWidth = ctx.measureText(label).width;
|
const textWidth = ctx.measureText(label).width;
|
||||||
const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2);
|
const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2);
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
ctx.fillRect(
|
ctx.fillRect(x - bckgDimensions[0] / 2, y - bckgDimensions[1] / 2, bckgDimensions[0], bckgDimensions[1]);
|
||||||
x - bckgDimensions[0] / 2,
|
|
||||||
y - bckgDimensions[1] / 2,
|
|
||||||
bckgDimensions[0],
|
|
||||||
bckgDimensions[1]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.fillStyle = 'black';
|
ctx.fillStyle = 'black';
|
||||||
ctx.fillText(label, x, y);
|
ctx.fillText(label, x, y);
|
||||||
}
|
}
|
||||||
@ -138,10 +142,8 @@ const ForceGraphComponent: React.FC<ForceGraphComponentProps> = ({
|
|||||||
// Always use linkCanvasObject for consistent rendering
|
// Always use linkCanvasObject for consistent rendering
|
||||||
if (graph.linkCanvasObject) {
|
if (graph.linkCanvasObject) {
|
||||||
graph.linkCanvasObject((link: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
graph.linkCanvasObject((link: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
// Draw the link line
|
|
||||||
const start = link.source as NodeObject;
|
const start = link.source as NodeObject;
|
||||||
const end = link.target as NodeObject;
|
const end = link.target as NodeObject;
|
||||||
|
|
||||||
if (!start || !end || typeof start.x !== 'number' || typeof end.x !== 'number' ||
|
if (!start || !end || typeof start.x !== 'number' || typeof end.x !== 'number' ||
|
||||||
typeof start.y !== 'number' || typeof end.y !== 'number') return;
|
typeof start.y !== 'number' || typeof end.y !== 'number') return;
|
||||||
|
|
||||||
@ -150,61 +152,43 @@ const ForceGraphComponent: React.FC<ForceGraphComponentProps> = ({
|
|||||||
const endX = end.x;
|
const endX = end.x;
|
||||||
const endY = end.y;
|
const endY = end.y;
|
||||||
|
|
||||||
|
// Draw the link line with theme color
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(startX, startY);
|
ctx.moveTo(startX, startY);
|
||||||
ctx.lineTo(endX, endY);
|
ctx.lineTo(endX, endY);
|
||||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
|
ctx.strokeStyle = linkColor;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw arrowhead regardless of label visibility
|
// Draw arrowhead with theme color
|
||||||
const arrowLength = 5;
|
const arrowLength = 5;
|
||||||
const dx = endX - startX;
|
const dx = endX - startX;
|
||||||
const dy = endY - startY;
|
const dy = endY - startY;
|
||||||
const angle = Math.atan2(dy, dx);
|
const angle = Math.atan2(dy, dx);
|
||||||
|
const arrowDistance = 15;
|
||||||
// Calculate a position near the target for the arrow
|
|
||||||
const arrowDistance = 15; // Distance from target node
|
|
||||||
const arrowX = endX - Math.cos(angle) * arrowDistance;
|
const arrowX = endX - Math.cos(angle) * arrowDistance;
|
||||||
const arrowY = endY - Math.sin(angle) * arrowDistance;
|
const arrowY = endY - Math.sin(angle) * arrowDistance;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(arrowX, arrowY);
|
ctx.moveTo(arrowX, arrowY);
|
||||||
ctx.lineTo(
|
ctx.lineTo(arrowX - arrowLength * Math.cos(angle - Math.PI / 6), arrowY - arrowLength * Math.sin(angle - Math.PI / 6));
|
||||||
arrowX - arrowLength * Math.cos(angle - Math.PI / 6),
|
ctx.lineTo(arrowX - arrowLength * Math.cos(angle + Math.PI / 6), arrowY - arrowLength * Math.sin(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.closePath();
|
||||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
ctx.fillStyle = arrowColor;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Only draw label if showLinkLabels is true
|
// Keep original label rendering
|
||||||
if (showLinkLabels) {
|
if (showLinkLabels) {
|
||||||
const label = link.type;
|
const label = link.type;
|
||||||
if (label) {
|
if (label) {
|
||||||
const fontSize = 10/globalScale;
|
const fontSize = 10/globalScale;
|
||||||
ctx.font = `${fontSize}px Sans-Serif`;
|
ctx.font = `${fontSize}px Sans-Serif`;
|
||||||
|
|
||||||
// Calculate middle point
|
|
||||||
const middleX = startX + (endX - startX) / 2;
|
const middleX = startX + (endX - startX) / 2;
|
||||||
const middleY = startY + (endY - startY) / 2;
|
const middleY = startY + (endY - startY) / 2;
|
||||||
|
|
||||||
// Add a background for better readability
|
|
||||||
const textWidth = ctx.measureText(label).width;
|
const textWidth = ctx.measureText(label).width;
|
||||||
const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2);
|
const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2);
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
ctx.fillRect(
|
ctx.fillRect(middleX - bckgDimensions[0] / 2, middleY - bckgDimensions[1] / 2, bckgDimensions[0], bckgDimensions[1]);
|
||||||
middleX - bckgDimensions[0] / 2,
|
|
||||||
middleY - bckgDimensions[1] / 2,
|
|
||||||
bckgDimensions[0],
|
|
||||||
bckgDimensions[1]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillStyle = 'black';
|
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();
|
initGraph();
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
|
const currentContainer = containerRef.current; // Store ref value
|
||||||
return () => {
|
return () => {
|
||||||
if (graphInstance && typeof graphInstance._destructor === 'function') {
|
if (graphInstance && typeof graphInstance._destructor === 'function') {
|
||||||
graphInstance._destructor();
|
graphInstance._destructor();
|
||||||
}
|
}
|
||||||
|
// Ensure container is cleared on cleanup too
|
||||||
|
if (currentContainer) { // Use the stored value in cleanup
|
||||||
|
currentContainer.innerHTML = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [data, width, height, showNodeLabels, showLinkLabels]);
|
}, [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 SearchSection from '@/components/search/SearchSection';
|
||||||
import ChatSection from '@/components/chat/ChatSection';
|
import ChatSection from '@/components/chat/ChatSection';
|
||||||
import GraphSection from '@/components/GraphSection';
|
import GraphSection from '@/components/GraphSection';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { AlertSystem } from '@/components/ui/alert-system';
|
import { AlertSystem } from '@/components/ui/alert-system';
|
||||||
import { extractTokenFromUri, getApiBaseUrlFromUri } from '@/lib/utils';
|
import { extractTokenFromUri, getApiBaseUrlFromUri } from '@/lib/utils';
|
||||||
import { MorphikUIProps } from '@/components/types';
|
import { MorphikUIProps } from '@/components/types';
|
||||||
@ -40,7 +39,6 @@ const MorphikUI: React.FC<MorphikUIProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const [activeSection, setActiveSection] = useState(initialSection);
|
const [activeSection, setActiveSection] = useState(initialSection);
|
||||||
const [selectedGraphName, setSelectedGraphName] = useState<string | undefined>(undefined);
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
// Extract auth token and API URL from connection URI if provided
|
// Extract auth token and API URL from connection URI if provided
|
||||||
@ -105,18 +103,9 @@ const MorphikUI: React.FC<MorphikUIProps> = ({
|
|||||||
{/* Graphs Section */}
|
{/* Graphs Section */}
|
||||||
{activeSection === 'graphs' && (
|
{activeSection === 'graphs' && (
|
||||||
<div className="space-y-4">
|
<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
|
<GraphSection
|
||||||
apiBaseUrl={effectiveApiBaseUrl}
|
apiBaseUrl={effectiveApiBaseUrl}
|
||||||
authToken={authToken}
|
authToken={authToken}
|
||||||
onSelectGraph={(graphName) => setSelectedGraphName(graphName)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { useMorphikChat } from '@/hooks/useMorphikChat';
|
||||||
import { Button } from '@/components/ui/button';
|
import { ChatMessage, Folder } from '@/components/types';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { generateUUID } from '@/lib/utils';
|
||||||
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 { 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 {
|
interface ChatSectionProps {
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
|
initialMessages?: ChatMessage[];
|
||||||
|
isReadonly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatSection: React.FC<ChatSectionProps> = ({ apiBaseUrl, authToken }) => {
|
/**
|
||||||
const [chatQuery, setChatQuery] = useState('');
|
* ChatSection component using Vercel-style UI
|
||||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
*/
|
||||||
const [loading, setLoading] = useState(false);
|
const ChatSection: React.FC<ChatSectionProps> = ({
|
||||||
const [showChatAdvanced, setShowChatAdvanced] = useState(false);
|
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 [availableGraphs, setAvailableGraphs] = useState<string[]>([]);
|
||||||
|
const [loadingGraphs, setLoadingGraphs] = useState(false);
|
||||||
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
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
|
// Fetch available graphs for dropdown
|
||||||
const fetchGraphs = useCallback(async () => {
|
const fetchGraphs = useCallback(async () => {
|
||||||
|
if (!apiBaseUrl) return;
|
||||||
|
|
||||||
|
setLoadingGraphs(true);
|
||||||
try {
|
try {
|
||||||
|
console.log(`Fetching graphs from: ${apiBaseUrl}/graphs`);
|
||||||
const response = await fetch(`${apiBaseUrl}/graphs`, {
|
const response = await fetch(`${apiBaseUrl}/graphs`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': authToken ? `Bearer ${authToken}` : ''
|
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
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) {
|
} catch (err) {
|
||||||
console.error('Error fetching available graphs:', err);
|
console.error('Error fetching available graphs:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingGraphs(false);
|
||||||
}
|
}
|
||||||
}, [apiBaseUrl, authToken]);
|
}, [apiBaseUrl, authToken]);
|
||||||
|
|
||||||
// Fetch graphs and folders when auth token or API URL changes
|
// Fetch folders
|
||||||
useEffect(() => {
|
const fetchFolders = useCallback(async () => {
|
||||||
if (authToken) {
|
if (!apiBaseUrl) return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
setLoadingFolders(true);
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
console.log(`Fetching folders from: ${apiBaseUrl}/folders`);
|
||||||
|
const response = await fetch(`${apiBaseUrl}/folders`, {
|
||||||
// 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',
|
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': authToken ? `Bearer ${authToken}` : '',
|
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
|
||||||
'Content-Type': 'application/json'
|
}
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: chatQuery,
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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
|
if (Array.isArray(foldersData)) {
|
||||||
const assistantMessage: ChatMessage = {
|
setFolders(foldersData);
|
||||||
role: 'assistant',
|
} else {
|
||||||
content: data.completion,
|
console.error('Expected array for folders data but received:', typeof foldersData);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setChatQuery(''); // Clear input
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
console.error('Error fetching folders:', err);
|
||||||
showAlert(errorMsg, {
|
|
||||||
type: 'error',
|
|
||||||
title: 'Chat Query Failed',
|
|
||||||
duration: 5000
|
|
||||||
});
|
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<Card className="h-full flex flex-col">
|
<div className="relative flex flex-col h-full w-full bg-background">
|
||||||
<CardHeader>
|
{/* Chat Header
|
||||||
<CardTitle>Chat with Your Documents</CardTitle>
|
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm">
|
||||||
<CardDescription>
|
<div className="flex h-14 items-center justify-between px-4 border-b">
|
||||||
Ask questions about your documents and get AI-powered answers.
|
<div className="flex items-center">
|
||||||
</CardDescription>
|
<h1 className="font-semibold text-lg tracking-tight">Morphik Chat</h1>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="flex-grow overflow-hidden flex flex-col">
|
</div>
|
||||||
<ScrollArea className="flex-grow pr-4 mb-4">
|
</div> */}
|
||||||
{chatMessages.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
{/* Messages Area */}
|
||||||
{chatMessages.map((message, index) => (
|
<div className="flex-1 relative">
|
||||||
<ChatMessageComponent
|
<ScrollArea className="h-full" ref={messagesContainerRef}>
|
||||||
key={index}
|
{messages.length === 0 && (
|
||||||
role={message.role}
|
<div className="flex-1 flex items-center justify-center p-8 text-center">
|
||||||
content={message.content}
|
<div className="max-w-md space-y-2">
|
||||||
sources={message.sources}
|
<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.
|
||||||
</div>
|
</p>
|
||||||
) : (
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t">
|
<div className="flex flex-col pt-4 pb-[80px] md:pb-[120px]">
|
||||||
<div className="space-y-4">
|
{messages.map((message) => (
|
||||||
<div className="flex gap-2">
|
<PreviewMessage
|
||||||
<Textarea
|
key={message.id}
|
||||||
placeholder="Ask a question..."
|
message={message}
|
||||||
value={chatQuery}
|
|
||||||
onChange={(e) => setChatQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleChat();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="min-h-10"
|
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleChat} disabled={loading}>
|
))}
|
||||||
{loading ? 'Sending...' : 'Send'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-2">
|
{status === 'submitted' &&
|
||||||
<p className="text-xs text-muted-foreground">
|
messages.length > 0 &&
|
||||||
Press Enter to send, Shift+Enter for a new line
|
messages[messages.length - 1].role === 'user' && (
|
||||||
</p>
|
<div className="flex items-center justify-center h-12 text-center text-xs text-muted-foreground">
|
||||||
|
<Spin className="mr-2 animate-spin" />
|
||||||
<ChatOptionsDialog
|
Thinking...
|
||||||
showChatAdvanced={showChatAdvanced}
|
</div>
|
||||||
setShowChatAdvanced={setShowChatAdvanced}
|
)
|
||||||
queryOptions={queryOptions}
|
}
|
||||||
updateQueryOption={updateQueryOption}
|
|
||||||
availableGraphs={availableGraphs}
|
|
||||||
folders={folders}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={messagesEndRef}
|
||||||
|
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="sticky bottom-0 w-full bg-background">
|
||||||
|
<div className="mx-auto px-4 sm:px-6 max-w-4xl">
|
||||||
|
<form
|
||||||
|
className="pb-6"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="absolute left-0 right-0 -top-20 h-24 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||||
|
<div className="relative flex items-end">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
placeholder="Send a message..."
|
||||||
|
value={input}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="min-h-[48px] max-h-[400px] resize-none overflow-hidden text-base w-full pr-16"
|
||||||
|
rows={1}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
!event.nativeEvent.isComposing
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (status !== 'ready') {
|
||||||
|
console.log('Please wait for the model to finish its response');
|
||||||
|
} else {
|
||||||
|
submitForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute bottom-2 right-2 flex items-center">
|
||||||
|
<Button
|
||||||
|
onClick={submitForm}
|
||||||
|
size="icon"
|
||||||
|
disabled={input.trim().length === 0 || status !== 'ready'}
|
||||||
|
className="h-8 w-8 rounded-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{status === 'streaming' ? (
|
||||||
|
<Spin className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">
|
||||||
|
{status === 'streaming' ? 'Processing' : 'Send message'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Button */}
|
||||||
|
{!isReadonly && (
|
||||||
|
<div className="flex justify-center mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSettings(!showSettings);
|
||||||
|
// Refresh data when opening settings
|
||||||
|
if (!showSettings && authToken) {
|
||||||
|
fetchGraphs();
|
||||||
|
fetchFolders();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
<span>{showSettings ? 'Hide Settings' : 'Show Settings'}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings Panel */}
|
||||||
|
{showSettings && !isReadonly && (
|
||||||
|
<div className="mt-4 border rounded-xl p-4 bg-muted/30 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-sm">Chat Settings</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => setShowSettings(false)}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* First Column - Core Settings */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="use_reranking" className="text-sm">Use Reranking</Label>
|
||||||
|
<Switch
|
||||||
|
id="use_reranking"
|
||||||
|
checked={queryOptions.use_reranking}
|
||||||
|
onCheckedChange={(checked) => updateQueryOption('use_reranking', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="use_colpali" className="text-sm">Use Colpali</Label>
|
||||||
|
<Switch
|
||||||
|
id="use_colpali"
|
||||||
|
checked={queryOptions.use_colpali}
|
||||||
|
onCheckedChange={(checked) => updateQueryOption('use_colpali', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="graph_name" className="text-sm block">Knowledge Graph</Label>
|
||||||
|
<Select
|
||||||
|
value={queryOptions.graph_name || "__none__"}
|
||||||
|
onValueChange={(value) => updateQueryOption('graph_name', value === "__none__" ? undefined : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full" id="graph_name">
|
||||||
|
<SelectValue placeholder="Select a knowledge graph" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None (Standard RAG)</SelectItem>
|
||||||
|
{loadingGraphs ? (
|
||||||
|
<SelectItem value="loading" disabled>Loading graphs...</SelectItem>
|
||||||
|
) : availableGraphs.length > 0 ? (
|
||||||
|
availableGraphs.map(graphName => (
|
||||||
|
<SelectItem key={graphName} value={graphName}>
|
||||||
|
{graphName}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="none_available" disabled>No graphs available</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="folder_name" className="text-sm block">Scope to Folder</Label>
|
||||||
|
<Select
|
||||||
|
value={queryOptions.folder_name || "__none__"}
|
||||||
|
onValueChange={(value) => updateQueryOption('folder_name', value === "__none__" ? undefined : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full" id="folder_name">
|
||||||
|
<SelectValue placeholder="Select a folder" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">All Folders</SelectItem>
|
||||||
|
{loadingFolders ? (
|
||||||
|
<SelectItem value="loading" disabled>Loading folders...</SelectItem>
|
||||||
|
) : folders.length > 0 ? (
|
||||||
|
folders.map(folder => (
|
||||||
|
<SelectItem key={folder.id || folder.name} value={folder.name}>
|
||||||
|
{folder.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="none_available" disabled>No folders available</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second Column - Advanced Settings */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="query-k" className="text-sm flex justify-between">
|
||||||
|
<span>Results (k)</span>
|
||||||
|
<span className="text-muted-foreground">{queryOptions.k}</span>
|
||||||
|
</Label>
|
||||||
|
<Slider
|
||||||
|
id="query-k"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
value={[queryOptions.k]}
|
||||||
|
onValueChange={(value) => updateQueryOption('k', value[0])}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="query-min-score" className="text-sm flex justify-between">
|
||||||
|
<span>Min Score</span>
|
||||||
|
<span className="text-muted-foreground">{queryOptions.min_score.toFixed(2)}</span>
|
||||||
|
</Label>
|
||||||
|
<Slider
|
||||||
|
id="query-min-score"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={[queryOptions.min_score]}
|
||||||
|
onValueChange={(value) => updateQueryOption('min_score', value[0])}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="query-temperature" className="text-sm flex justify-between">
|
||||||
|
<span>Temperature</span>
|
||||||
|
<span className="text-muted-foreground">{queryOptions.temperature.toFixed(2)}</span>
|
||||||
|
</Label>
|
||||||
|
<Slider
|
||||||
|
id="query-temperature"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={[queryOptions.temperature]}
|
||||||
|
onValueChange={(value) => updateQueryOption('temperature', value[0])}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="query-max-tokens" className="text-sm flex justify-between">
|
||||||
|
<span>Max Tokens</span>
|
||||||
|
<span className="text-muted-foreground">{queryOptions.max_tokens}</span>
|
||||||
|
</Label>
|
||||||
|
<Slider
|
||||||
|
id="query-max-tokens"
|
||||||
|
min={1}
|
||||||
|
max={2048}
|
||||||
|
step={1}
|
||||||
|
value={[queryOptions.max_tokens]}
|
||||||
|
onValueChange={(value) => updateQueryOption('max_tokens', value[0])}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Upload } from 'lucide-react';
|
import { Upload } from 'lucide-react';
|
||||||
import { showAlert, removeAlert } from '@/components/ui/alert-system';
|
import { showAlert, removeAlert } from '@/components/ui/alert-system';
|
||||||
import DocumentList from './DocumentList';
|
import DocumentList from './DocumentList';
|
||||||
@ -135,300 +134,195 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
|
|||||||
|
|
||||||
// Fetch all documents, optionally filtered by folder
|
// Fetch all documents, optionally filtered by folder
|
||||||
const fetchDocuments = useCallback(async (source: string = 'unknown') => {
|
const fetchDocuments = useCallback(async (source: string = 'unknown') => {
|
||||||
console.log(`fetchDocuments called from: ${source}`)
|
console.log(`fetchDocuments called from: ${source}, selectedFolder: ${selectedFolder}`);
|
||||||
try {
|
// Ensure API URL is valid before proceeding
|
||||||
// Only set loading state for initial load, not for refreshes
|
if (!effectiveApiUrl) {
|
||||||
if (documents.length === 0) {
|
console.error('fetchDocuments: No valid API URL available.');
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't fetch if no folder is selected (showing folder grid view)
|
|
||||||
if (selectedFolder === null) {
|
|
||||||
setDocuments([]);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare for document fetching
|
// Immediately clear documents and set loading state if selectedFolder is null (folder grid view)
|
||||||
let apiUrl = `${effectiveApiUrl}/documents`;
|
if (selectedFolder === null) {
|
||||||
// CRITICAL FIX: The /documents endpoint uses POST method
|
console.log('fetchDocuments: No folder selected, clearing documents.');
|
||||||
let method = 'POST';
|
setDocuments([]);
|
||||||
let requestBody = {};
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If we're looking at a specific folder (not "all" documents)
|
// Set loading state only for initial load or when explicitly changing folders
|
||||||
if (selectedFolder && selectedFolder !== "all") {
|
if (documents.length === 0 || source === 'folders loaded or selectedFolder changed') {
|
||||||
console.log(`Fetching documents for folder: ${selectedFolder}`);
|
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);
|
const targetFolder = folders.find(folder => folder.name === selectedFolder);
|
||||||
|
|
||||||
if (targetFolder) {
|
if (targetFolder && Array.isArray(targetFolder.document_ids) && targetFolder.document_ids.length > 0) {
|
||||||
// Ensure document_ids is always an array
|
// Folder found and has documents, fetch them by ID
|
||||||
const documentIds = Array.isArray(targetFolder.document_ids) ? targetFolder.document_ids : [];
|
console.log(`fetchDocuments: Folder found with ${targetFolder.document_ids.length} IDs. Fetching details...`);
|
||||||
|
const response = await fetch(`${effectiveApiUrl}/batch/documents`, {
|
||||||
if (documentIds.length > 0) {
|
method: 'POST',
|
||||||
// If we found the folder and it contains documents,
|
headers: {
|
||||||
// Get document details for each document ID in the folder
|
'Content-Type': 'application/json',
|
||||||
console.log(`Found folder ${targetFolder.name} with ${documentIds.length} documents`);
|
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
|
||||||
|
},
|
||||||
// Use batch/documents endpoint which accepts document_ids for efficient fetching
|
body: JSON.stringify({ document_ids: targetFolder.document_ids })
|
||||||
apiUrl = `${effectiveApiUrl}/batch/documents`;
|
});
|
||||||
method = 'POST';
|
if (!response.ok) {
|
||||||
requestBody = {
|
throw new Error(`Failed to fetch batch documents: ${response.statusText}`);
|
||||||
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 = {};
|
|
||||||
}
|
}
|
||||||
|
documentsToFetch = await response.json();
|
||||||
|
console.log(`fetchDocuments: Fetched details for ${documentsToFetch.length} documents`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Folder ${selectedFolder} has no documents or couldn't be found`);
|
// Folder not found, or folder is empty
|
||||||
// For unknown folder, we'll send an empty request body to the documents endpoint
|
if (targetFolder) {
|
||||||
requestBody = {};
|
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}`);
|
// Process fetched documents (add status if needed)
|
||||||
|
const processedData = documentsToFetch.map((doc: Document) => {
|
||||||
// Use non-blocking fetch with appropriate method
|
if (!doc.system_metadata) {
|
||||||
fetch(apiUrl, {
|
doc.system_metadata = {};
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
return response.json();
|
if (!doc.system_metadata.status && doc.system_metadata.folder_name) {
|
||||||
})
|
doc.system_metadata.status = "processing";
|
||||||
.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`);
|
|
||||||
}
|
}
|
||||||
})
|
return doc;
|
||||||
.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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setDocuments(processedData);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
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, {
|
showAlert(errorMsg, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
duration: 5000
|
duration: 5000
|
||||||
});
|
});
|
||||||
setLoading(false);
|
} finally {
|
||||||
}
|
|
||||||
}, [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}`);
|
|
||||||
setFoldersLoading(false);
|
setFoldersLoading(false);
|
||||||
}
|
}
|
||||||
}, [effectiveApiUrl, authToken]);
|
}, [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
|
// Fetch folders initially
|
||||||
// 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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only run this effect if we have auth or are on localhost
|
console.log('DocumentsSection: Initial folder fetch');
|
||||||
if (authToken || effectiveApiUrl.includes('localhost')) {
|
fetchFolders();
|
||||||
console.log('DocumentsSection: Fetching initial data');
|
}, [fetchFolders]);
|
||||||
|
|
||||||
// Clear current data and reset state
|
// Fetch documents when folders are loaded or selectedFolder changes
|
||||||
setDocuments([]);
|
useEffect(() => {
|
||||||
setFolders([]);
|
if (!foldersLoading && folders.length > 0) {
|
||||||
setSelectedDocument(null);
|
// Avoid fetching documents on initial mount if selectedFolder is null
|
||||||
setSelectedDocuments([]);
|
// 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
|
// Poll for document status if any document is processing
|
||||||
let isMounted = true;
|
useEffect(() => {
|
||||||
|
const hasProcessing = documents.some(
|
||||||
|
(doc) => doc.system_metadata?.status === 'processing'
|
||||||
|
);
|
||||||
|
|
||||||
// Create an abort controller for request cancellation
|
if (hasProcessing) {
|
||||||
const controller = new AbortController();
|
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
|
// Cleanup function to clear the interval when the component unmounts
|
||||||
const timeoutId = setTimeout(() => {
|
// or when there are no more processing documents
|
||||||
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
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
console.log('Clearing polling interval');
|
||||||
isMounted = false;
|
clearInterval(intervalId);
|
||||||
controller.abort();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [documents, refreshDocuments]);
|
||||||
}, [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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collapse sidebar when a folder is selected
|
// Collapse sidebar when a folder is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -439,89 +333,6 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selectedFolder, setSidebarCollapsed]);
|
}, [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
|
// Fetch a specific document by ID
|
||||||
const fetchDocument = async (documentId: string) => {
|
const fetchDocument = async (documentId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -797,35 +608,13 @@ const DocumentsSection: React.FC<DocumentsSectionProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Force a fresh refresh after upload
|
// Force a fresh refresh after upload
|
||||||
// This is a special function to ensure we get truly fresh data
|
|
||||||
const refreshAfterUpload = async () => {
|
const refreshAfterUpload = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Performing fresh refresh after upload");
|
console.log("Performing fresh refresh after upload (file)");
|
||||||
// Clear folder data to force a clean refresh
|
// ONLY fetch folders. The useEffect watching folders will trigger fetchDocuments.
|
||||||
setFolders([]);
|
await fetchFolders();
|
||||||
|
|
||||||
// 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);
|
|
||||||
} catch (err) {
|
} 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
|
// Force a fresh refresh after upload
|
||||||
// This is a special function to ensure we get truly fresh data
|
|
||||||
const refreshAfterUpload = async () => {
|
const refreshAfterUpload = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Performing fresh refresh after upload");
|
console.log("Performing fresh refresh after upload (batch)");
|
||||||
// Clear folder data to force a clean refresh
|
// ONLY fetch folders. The useEffect watching folders will trigger fetchDocuments.
|
||||||
setFolders([]);
|
await fetchFolders();
|
||||||
|
|
||||||
// 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);
|
|
||||||
} catch (err) {
|
} 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
|
// Force a fresh refresh after upload
|
||||||
// This is a special function to ensure we get truly fresh data
|
|
||||||
const refreshAfterUpload = async () => {
|
const refreshAfterUpload = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Performing fresh refresh after upload");
|
console.log("Performing fresh refresh after upload (text)");
|
||||||
// Clear folder data to force a clean refresh
|
// ONLY fetch folders. The useEffect watching folders will trigger fetchDocuments.
|
||||||
setFolders([]);
|
await fetchFolders();
|
||||||
|
|
||||||
// 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);
|
|
||||||
} catch (err) {
|
} 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
|
// Function to trigger refresh
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
console.log("Manual refresh triggered");
|
console.log("Manual refresh triggered");
|
||||||
// Show a loading indicator
|
|
||||||
showAlert("Refreshing documents and folders...", {
|
showAlert("Refreshing documents and folders...", {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
duration: 1500
|
duration: 1500
|
||||||
});
|
});
|
||||||
|
|
||||||
// First clear folder data to force a clean refresh
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setFolders([]);
|
|
||||||
|
|
||||||
// Create a new function to perform a truly fresh fetch
|
// Create a new function to perform a truly fresh fetch
|
||||||
const performFreshFetch = async () => {
|
const performFreshFetch = async () => {
|
||||||
try {
|
try {
|
||||||
// First get fresh folder data from the server
|
// ONLY fetch folders. The useEffect watching folders will trigger fetchDocuments.
|
||||||
const folderResponse = await fetch(`${effectiveApiUrl}/folders`, {
|
await fetchFolders();
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!folderResponse.ok) {
|
// Show success message (consider moving this if fetchFolders doesn't guarantee documents are loaded)
|
||||||
throw new Error(`Failed to fetch folders: ${folderResponse.statusText}`);
|
showAlert("Refresh initiated. Data will update shortly.", {
|
||||||
}
|
|
||||||
|
|
||||||
// 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", {
|
|
||||||
type: 'success',
|
type: 'success',
|
||||||
duration: 1500
|
duration: 1500
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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'}`, {
|
showAlert(`Error refreshing: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
duration: 3000
|
duration: 3000
|
||||||
});
|
});
|
||||||
} finally {
|
} 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Hide the main header when viewing a specific folder - it will be merged with the FolderList header */}
|
{/* Folder view controls - only show when not in a specific folder */}
|
||||||
{selectedFolder === null && (
|
{/* No longer needed - controls will be provided in FolderList */}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Render the FolderList with header at all times when selectedFolder is not null */}
|
{/* Render the FolderList with header at all times when selectedFolder is not null */}
|
||||||
{selectedFolder !== null && (
|
{selectedFolder !== null && (
|
||||||
|
@ -209,7 +209,6 @@ const FolderList: React.FC<FolderListProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="font-medium text-lg">Folders</h2>
|
|
||||||
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@ -230,8 +229,7 @@ const FolderList: React.FC<FolderListProps> = ({
|
|||||||
id="folderName"
|
id="folderName"
|
||||||
value={newFolderName}
|
value={newFolderName}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
placeholder="My Folder"
|
placeholder="Enter folder name"
|
||||||
className="mt-1"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -240,22 +238,39 @@ const FolderList: React.FC<FolderListProps> = ({
|
|||||||
id="folderDescription"
|
id="folderDescription"
|
||||||
value={newFolderDescription}
|
value={newFolderDescription}
|
||||||
onChange={(e) => setNewFolderDescription(e.target.value)}
|
onChange={(e) => setNewFolderDescription(e.target.value)}
|
||||||
placeholder="Enter a description for this folder"
|
placeholder="Enter folder description"
|
||||||
className="mt-1"
|
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setShowNewFolderDialog(false)}>
|
<Button variant="ghost" onClick={() => setShowNewFolderDialog(false)} disabled={isCreatingFolder}>Cancel</Button>
|
||||||
Cancel
|
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim() || isCreatingFolder}>
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreateFolder} disabled={isCreatingFolder || !newFolderName.trim()}>
|
|
||||||
{isCreatingFolder ? 'Creating...' : 'Create Folder'}
|
{isCreatingFolder ? 'Creating...' : 'Create Folder'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6 py-2">
|
<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"
|
className="cursor-pointer group flex flex-col items-center"
|
||||||
onClick={() => updateSelectedFolder("all")}
|
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>
|
<span className="text-4xl" aria-hidden="true">📄</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-center group-hover:text-primary transition-colors">All Documents</span>
|
<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"
|
className="cursor-pointer group flex flex-col items-center"
|
||||||
onClick={() => updateSelectedFolder(folder.name)}
|
onClick={() => updateSelectedFolder(folder.name)}
|
||||||
>
|
>
|
||||||
<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">
|
||||||
<Image src="/icons/folder-icon.png" alt="Folder" width={64} height={64} />
|
<Image src="/icons/folder-icon.png" alt="Folder" width={64} height={64} className="object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium truncate text-center w-full max-w-[100px] group-hover:text-primary transition-colors">{folder.name}</span>
|
<span className="text-sm font-medium truncate text-center w-full max-w-[100px] group-hover:text-primary transition-colors">{folder.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@ -122,14 +121,8 @@ const SearchSection: React.FC<SearchSectionProps> = ({ apiBaseUrl, authToken })
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex-1 flex flex-col h-full">
|
<div className="flex-1 flex flex-col h-full p-4">
|
||||||
<CardHeader>
|
<div className="flex-1 flex flex-col">
|
||||||
<CardTitle>Search Documents</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Search across your documents to find relevant information.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1 flex flex-col">
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@ -179,8 +172,8 @@ const SearchSection: React.FC<SearchSectionProps> = ({ apiBaseUrl, authToken })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</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"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines class names using clsx and tailwind-merge
|
||||||
|
*/
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
@ -110,3 +113,49 @@ export function createAuthHeaders(token: string | null, contentType?: string): H
|
|||||||
|
|
||||||
return headers;
|
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"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^1.2.9",
|
||||||
"@radix-ui/primitive": "^1.1.2",
|
"@radix-ui/primitive": "^1.1.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.5",
|
"@radix-ui/react-checkbox": "^1.1.5",
|
||||||
@ -50,8 +51,10 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.4",
|
||||||
"accessor-fn": "^1.5.3",
|
"accessor-fn": "^1.5.3",
|
||||||
"accordion": "^3.0.2",
|
"accordion": "^3.0.2",
|
||||||
|
"ai": "^4.3.10",
|
||||||
"alert": "^6.0.2",
|
"alert": "^6.0.2",
|
||||||
"caniuse-lite": "^1.0.30001714",
|
"caniuse-lite": "^1.0.30001714",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -76,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20.17.31",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
|
@ -18,7 +18,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"],
|
||||||
|
"@/components/*": ["./components/*"],
|
||||||
|
"@/hooks/*": ["./hooks/*"],
|
||||||
|
"@/lib/*": ["./lib/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
@ -53,6 +53,7 @@ def should_ignore_directory(dirname: str) -> bool:
|
|||||||
"build",
|
"build",
|
||||||
"dist",
|
"dist",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
".next",
|
||||||
}
|
}
|
||||||
return dirname in ignore_dirs
|
return dirname in ignore_dirs
|
||||||
|
|
||||||
@ -66,14 +67,15 @@ def get_target_directories(mode: str, root_dir: str) -> Set[str]:
|
|||||||
"core": ["core"],
|
"core": ["core"],
|
||||||
"sdk": ["sdks"],
|
"sdk": ["sdks"],
|
||||||
"test": ["core/tests", "sdks/python/tests"],
|
"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, [])}
|
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:
|
Args:
|
||||||
root_dir: Root directory to start search
|
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)
|
rel_path = os.path.relpath(os.path.join(dirpath, d), root_dir)
|
||||||
tree.add_path(rel_path, is_file=False)
|
tree.add_path(rel_path, is_file=False)
|
||||||
|
|
||||||
# Process Python files
|
# Determine relevant file extensions based on mode
|
||||||
python_files = [
|
if mode == "ui-component":
|
||||||
|
relevant_extensions = (".js", ".jsx", ".ts", ".tsx", ".css", ".html", ".json")
|
||||||
|
else:
|
||||||
|
relevant_extensions = (".py",)
|
||||||
|
|
||||||
|
# Process relevant files
|
||||||
|
relevant_files = [
|
||||||
f
|
f
|
||||||
for f in filenames
|
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:
|
for file_name in relevant_files:
|
||||||
file_path = os.path.join(dirpath, py_file)
|
file_path = os.path.join(dirpath, file_name)
|
||||||
rel_path = os.path.relpath(file_path, root_dir)
|
rel_path = os.path.relpath(file_path, root_dir)
|
||||||
|
|
||||||
# Add file to tree
|
# Add file to tree
|
||||||
@ -143,14 +151,20 @@ Root Directory: {root_dir}
|
|||||||
for dirpath, dirnames, filenames in os.walk(target_dir, topdown=True):
|
for dirpath, dirnames, filenames in os.walk(target_dir, topdown=True):
|
||||||
dirnames[:] = [d for d in dirnames if not should_ignore_directory(d)]
|
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
|
f
|
||||||
for f in filenames
|
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:
|
for file_name in relevant_files:
|
||||||
file_path = os.path.join(dirpath, py_file)
|
file_path = os.path.join(dirpath, file_name)
|
||||||
rel_path = os.path.relpath(file_path, root_dir)
|
rel_path = os.path.relpath(file_path, root_dir)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -174,7 +188,7 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description="Aggregate Python files with directory structure")
|
parser = argparse.ArgumentParser(description="Aggregate Python files with directory structure")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--mode",
|
"--mode",
|
||||||
choices=["all", "core", "sdk", "test"],
|
choices=["all", "core", "sdk", "test", "ui-component"],
|
||||||
default="all",
|
default="all",
|
||||||
help="Which directories to process",
|
help="Which directories to process",
|
||||||
)
|
)
|
||||||
@ -189,7 +203,7 @@ def main():
|
|||||||
print(f"Output: {args.output}")
|
print(f"Output: {args.output}")
|
||||||
print(f"Root directory: {current_dir}")
|
print(f"Root directory: {current_dir}")
|
||||||
|
|
||||||
aggregate_python_files(
|
aggregate_files(
|
||||||
root_dir=current_dir,
|
root_dir=current_dir,
|
||||||
output_file=args.output,
|
output_file=args.output,
|
||||||
script_name=script_name,
|
script_name=script_name,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user