mirror of
https://github.com/james-m-jordan/morphik-core.git
synced 2025-05-09 19:32:38 +00:00
UI component: dark mode, packaging, bug fixes (#86)
This commit is contained in:
parent
86ec969736
commit
737039e988
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "morphik-core",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
20
ui-component/.npmignore
Normal file
20
ui-component/.npmignore
Normal file
@ -0,0 +1,20 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
app
|
||||
public
|
||||
.next
|
||||
.vercel
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Exclude development configs
|
||||
tsconfig.json
|
||||
postcss.config.mjs
|
||||
tailwind.config.ts
|
||||
next.config.mjs
|
||||
*.lock
|
||||
components.json
|
||||
|
||||
# Include dist
|
||||
!dist
|
67
ui-component/PUBLISHING.md
Normal file
67
ui-component/PUBLISHING.md
Normal file
@ -0,0 +1,67 @@
|
||||
# Publishing the @morphik/ui Package
|
||||
|
||||
This document provides instructions for publishing the @morphik/ui package to npm.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. You must have an npm account
|
||||
2. You must be added as a contributor to the @morphik organization
|
||||
3. You must be logged in to npm via the CLI (`npm login`)
|
||||
|
||||
## Publishing Steps
|
||||
|
||||
1. Ensure all changes are committed to the repository
|
||||
|
||||
2. Update the version in package.json (follow semantic versioning)
|
||||
```bash
|
||||
npm version patch # For bug fixes
|
||||
npm version minor # For new features
|
||||
npm version major # For breaking changes
|
||||
```
|
||||
|
||||
3. Build the package
|
||||
```bash
|
||||
npm run build:package
|
||||
```
|
||||
|
||||
4. Run a dry-run to check the package contents
|
||||
```bash
|
||||
npm pack --dry-run
|
||||
```
|
||||
|
||||
5. Publish the package
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
6. Create a git tag for the release
|
||||
```bash
|
||||
git tag v$(node -p "require('./package.json').version")
|
||||
git push origin v$(node -p "require('./package.json').version")
|
||||
```
|
||||
|
||||
## Installing for Local Development
|
||||
|
||||
If you want to test the package locally before publishing, you can use npm link:
|
||||
|
||||
1. In the ui-component directory:
|
||||
```bash
|
||||
npm link
|
||||
```
|
||||
|
||||
2. In your project that uses the package:
|
||||
```bash
|
||||
npm link @morphik/ui
|
||||
```
|
||||
|
||||
Alternatively, you can install from a local directory:
|
||||
|
||||
```bash
|
||||
npm install /path/to/morphik-core/ui-component
|
||||
```
|
||||
|
||||
Or from a GitHub repository:
|
||||
|
||||
```bash
|
||||
npm install github:morphik-org/morphik-core#subdirectory=ui-component
|
||||
```
|
@ -1,4 +1,4 @@
|
||||
# Morphik UI Component
|
||||
# @morphik/ui
|
||||
|
||||
A modern React-based UI for Morphik, built with Next.js and Tailwind CSS. This component provides a user-friendly interface for:
|
||||
- Document management and uploads
|
||||
@ -6,26 +6,54 @@ A modern React-based UI for Morphik, built with Next.js and Tailwind CSS. This c
|
||||
- Real-time document processing feedback
|
||||
- Query testing and prototyping
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @morphik/ui
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```jsx
|
||||
import { MorphikUI } from '@morphik/ui';
|
||||
|
||||
export default function YourApp() {
|
||||
return (
|
||||
<MorphikUI
|
||||
connectionUri="your-connection-uri"
|
||||
apiBaseUrl="http://your-api-base-url"
|
||||
isReadOnlyUri={false}
|
||||
onUriChange={(uri) => console.log('URI changed:', uri)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `connectionUri` | `string` | `undefined` | Connection URI for Morphik API |
|
||||
| `apiBaseUrl` | `string` | `"http://localhost:8000"` | Base URL for API requests |
|
||||
| `isReadOnlyUri` | `boolean` | `false` | Controls whether the URI can be edited |
|
||||
| `onUriChange` | `(uri: string) => void` | `undefined` | Callback when URI is changed |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18 or later
|
||||
- npm or yarn package manager
|
||||
- A running Morphik server
|
||||
|
||||
## Quick Start
|
||||
## Development Quick Start
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
2. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
3. Open [http://localhost:3000](http://localhost:3000) in your browser
|
||||
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { AlertSystem } from "@/components/ui/alert-system";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
@ -25,12 +26,19 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<AlertSystem position="bottom-right" />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<AlertSystem position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -223,7 +223,7 @@ const ForceGraphComponent: React.FC<ForceGraphComponentProps> = ({
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center p-4">
|
||||
<h3 class="text-lg font-medium mb-2">Graph Visualization Error</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
There was an error initializing the graph visualization.
|
||||
</p>
|
||||
</div>
|
||||
|
@ -55,6 +55,8 @@ interface Relationship {
|
||||
|
||||
interface GraphSectionProps {
|
||||
apiBaseUrl: string;
|
||||
onSelectGraph?: (graphName: string | undefined) => void;
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
// Map entity types to colors
|
||||
@ -69,7 +71,21 @@ const entityTypeColors: Record<string, string> = {
|
||||
'default': '#6b7280' // Gray
|
||||
};
|
||||
|
||||
const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl, onSelectGraph, authToken }) => {
|
||||
// Create auth headers for API requests if auth token is available
|
||||
const createHeaders = useCallback((contentType?: string): HeadersInit => {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}, [authToken]);
|
||||
// State variables
|
||||
const [graphs, setGraphs] = useState<Graph[]>([]);
|
||||
const [selectedGraph, setSelectedGraph] = useState<Graph | null>(null);
|
||||
@ -136,7 +152,8 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
const fetchGraphs = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${apiBaseUrl}/graphs`);
|
||||
const headers = createHeaders();
|
||||
const response = await fetch(`${apiBaseUrl}/graphs`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch graphs: ${response.statusText}`);
|
||||
@ -151,7 +168,7 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiBaseUrl]);
|
||||
}, [apiBaseUrl, createHeaders]);
|
||||
|
||||
// Fetch graphs on component mount
|
||||
useEffect(() => {
|
||||
@ -162,7 +179,11 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
const fetchGraph = async (graphName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${apiBaseUrl}/graph/${encodeURIComponent(graphName)}`);
|
||||
const headers = createHeaders();
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/graph/${encodeURIComponent(graphName)}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch graph: ${response.statusText}`);
|
||||
@ -171,6 +192,11 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
const data = await response.json();
|
||||
setSelectedGraph(data);
|
||||
|
||||
// Call the callback if provided
|
||||
if (onSelectGraph) {
|
||||
onSelectGraph(graphName);
|
||||
}
|
||||
|
||||
// Change to visualize tab if we're not on the list tab
|
||||
if (activeTab !== 'list' && activeTab !== 'create') {
|
||||
setActiveTab('visualize');
|
||||
@ -211,11 +237,10 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
throw new Error('Invalid JSON in filters field');
|
||||
}
|
||||
|
||||
const headers = createHeaders('application/json');
|
||||
const response = await fetch(`${apiBaseUrl}/graph/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name: graphName,
|
||||
filters: Object.keys(parsedFilters).length > 0 ? parsedFilters : undefined,
|
||||
@ -270,11 +295,10 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
throw new Error('Invalid JSON in additional filters field');
|
||||
}
|
||||
|
||||
const headers = createHeaders('application/json');
|
||||
const response = await fetch(`${apiBaseUrl}/graph/${encodeURIComponent(selectedGraph.name)}/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
additional_filters: Object.keys(parsedFilters).length > 0 ? parsedFilters : undefined,
|
||||
additional_documents: additionalDocuments.length > 0 ? additionalDocuments : undefined,
|
||||
@ -335,9 +359,9 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[900px] border rounded-md">
|
||||
<div className="text-center p-8">
|
||||
<Network className="h-16 w-16 mx-auto mb-4 text-gray-400" />
|
||||
<Network className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-medium mb-2">No Graph Selected</h3>
|
||||
<p className="text-gray-500">
|
||||
<p className="text-muted-foreground">
|
||||
Select a graph from the list to visualize it here.
|
||||
</p>
|
||||
</div>
|
||||
@ -402,7 +426,7 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-muted-foreground">
|
||||
Knowledge graphs represent relationships between entities extracted from your documents.
|
||||
Use them to enhance your queries with structured information and improve retrieval quality.
|
||||
</p>
|
||||
@ -435,8 +459,8 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
</div>
|
||||
) : graphs.length === 0 ? (
|
||||
<div className="text-center p-8 border-2 border-dashed rounded-lg">
|
||||
<Network className="mx-auto h-12 w-12 mb-3 text-gray-400" />
|
||||
<p className="text-gray-500 mb-3">No graphs available.</p>
|
||||
<Network className="mx-auto h-12 w-12 mb-3 text-muted-foreground" />
|
||||
<p className="text-muted-foreground mb-3">No graphs available.</p>
|
||||
<Button onClick={() => setActiveTab('create')} variant="default">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Graph
|
||||
@ -479,7 +503,7 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
<Badge variant="outline">+{Array.from(new Set(graph.entities.map(e => e.type))).length - 5} more</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
{graph.document_ids.length} document{graph.document_ids.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -504,25 +528,25 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-blue-700 dark:text-blue-300 mb-1">Documents</h4>
|
||||
<div className="text-2xl font-bold">{selectedGraph.document_ids.length}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">source documents</div>
|
||||
<div className="text-sm text-muted-foreground">source documents</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-emerald-700 dark:text-emerald-300 mb-1">Entities</h4>
|
||||
<div className="text-2xl font-bold">{selectedGraph.entities.length}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">unique elements</div>
|
||||
<div className="text-sm text-muted-foreground">unique elements</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-amber-700 dark:text-amber-300 mb-1">Relationships</h4>
|
||||
<div className="text-2xl font-bold">{selectedGraph.relationships.length}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">connections</div>
|
||||
<div className="text-sm text-muted-foreground">connections</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-purple-700 dark:text-purple-300 mb-1">Created</h4>
|
||||
<div className="text-xl font-bold">{new Date(selectedGraph.created_at).toLocaleDateString()}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(selectedGraph.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
@ -740,8 +764,8 @@ const GraphSection: React.FC<GraphSectionProps> = ({ apiBaseUrl }) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 border-2 border-dashed rounded-lg">
|
||||
<Network className="mx-auto h-12 w-12 mb-3 text-gray-400" />
|
||||
<p className="text-gray-500 mb-3">Please select a graph to update.</p>
|
||||
<Network className="mx-auto h-12 w-12 mb-3 text-muted-foreground" />
|
||||
<p className="text-muted-foreground mb-3">Please select a graph to update.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
141
ui-component/components/MorphikUI.tsx
Normal file
141
ui-component/components/MorphikUI.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Sidebar } from '@/components/ui/sidebar';
|
||||
import DocumentsSection from '@/components/documents/DocumentsSection';
|
||||
import SearchSection from '@/components/search/SearchSection';
|
||||
import ChatSection from '@/components/chat/ChatSection';
|
||||
import GraphSection from '@/components/GraphSection';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertSystem } from '@/components/ui/alert-system';
|
||||
import { extractTokenFromUri, getApiBaseUrlFromUri } from '@/lib/utils';
|
||||
import { MorphikUIProps } from '@/components/types';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
// Default API base URL
|
||||
const DEFAULT_API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
const MorphikUI: React.FC<MorphikUIProps> = ({
|
||||
connectionUri,
|
||||
apiBaseUrl = DEFAULT_API_BASE_URL,
|
||||
isReadOnlyUri = false, // Default to editable URI
|
||||
onUriChange,
|
||||
onBackClick
|
||||
}) => {
|
||||
// State to manage connectionUri internally if needed
|
||||
const [currentUri, setCurrentUri] = useState(connectionUri);
|
||||
|
||||
// Update internal state when prop changes
|
||||
useEffect(() => {
|
||||
setCurrentUri(connectionUri);
|
||||
}, [connectionUri]);
|
||||
|
||||
// Handle URI changes from sidebar
|
||||
const handleUriChange = (newUri: string) => {
|
||||
console.log('MorphikUI: URI changed to:', newUri);
|
||||
setCurrentUri(newUri);
|
||||
if (onUriChange) {
|
||||
onUriChange(newUri);
|
||||
}
|
||||
};
|
||||
const [activeSection, setActiveSection] = useState('documents');
|
||||
const [selectedGraphName, setSelectedGraphName] = useState<string | undefined>(undefined);
|
||||
|
||||
// Extract auth token and API URL from connection URI if provided
|
||||
const authToken = currentUri ? extractTokenFromUri(currentUri) : null;
|
||||
|
||||
// Derive API base URL from the URI if provided
|
||||
// If URI is empty, this will now connect to localhost by default
|
||||
const effectiveApiBaseUrl = getApiBaseUrlFromUri(currentUri, apiBaseUrl);
|
||||
|
||||
// Log the effective API URL for debugging
|
||||
useEffect(() => {
|
||||
console.log('MorphikUI: Using API URL:', effectiveApiBaseUrl);
|
||||
console.log('MorphikUI: Auth token present:', !!authToken);
|
||||
}, [effectiveApiBaseUrl, authToken]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
className="h-screen"
|
||||
connectionUri={currentUri}
|
||||
isReadOnlyUri={isReadOnlyUri}
|
||||
onUriChange={handleUriChange}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||
{/* Header with back button */}
|
||||
{onBackClick && (
|
||||
<div className="bg-background border-b p-3 flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBackClick}
|
||||
className="mr-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to dashboard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col overflow-hidden">
|
||||
{/* Documents Section */}
|
||||
{activeSection === 'documents' && (
|
||||
<DocumentsSection
|
||||
apiBaseUrl={effectiveApiBaseUrl}
|
||||
authToken={authToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Section */}
|
||||
{activeSection === 'search' && (
|
||||
<SearchSection
|
||||
apiBaseUrl={effectiveApiBaseUrl}
|
||||
authToken={authToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chat Section */}
|
||||
{activeSection === 'chat' && (
|
||||
<ChatSection
|
||||
apiBaseUrl={effectiveApiBaseUrl}
|
||||
authToken={authToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notebooks Section - Removed */}
|
||||
|
||||
{/* Graphs Section */}
|
||||
{activeSection === 'graphs' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end items-center">
|
||||
{selectedGraphName && (
|
||||
<Badge variant="outline" className="bg-blue-50 px-3 py-1">
|
||||
Current Query Graph: {selectedGraphName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GraphSection
|
||||
apiBaseUrl={effectiveApiBaseUrl}
|
||||
authToken={authToken}
|
||||
onSelectGraph={(graphName) => setSelectedGraphName(graphName)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global alert system - integrated directly in the component */}
|
||||
<AlertSystem position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MorphikUI;
|
File diff suppressed because it is too large
Load Diff
27
ui-component/components/chat/ChatMessage.tsx
Normal file
27
ui-component/components/chat/ChatMessage.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// Define our own props interface to avoid empty interface error
|
||||
interface ChatMessageProps {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
const ChatMessageComponent: React.FC<ChatMessageProps> = ({ role, content }) => {
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageComponent;
|
165
ui-component/components/chat/ChatOptionsDialog.tsx
Normal file
165
ui-component/components/chat/ChatOptionsDialog.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
"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 } 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[];
|
||||
}
|
||||
|
||||
const ChatOptionsDialog: React.FC<ChatOptionsDialogProps> = ({
|
||||
showChatAdvanced,
|
||||
setShowChatAdvanced,
|
||||
queryOptions,
|
||||
updateQueryOption,
|
||||
availableGraphs
|
||||
}) => {
|
||||
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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowChatAdvanced(false)}>Apply</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatOptionsDialog;
|
158
ui-component/components/chat/ChatOptionsPanel.tsx
Normal file
158
ui-component/components/chat/ChatOptionsPanel.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
"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;
|
205
ui-component/components/chat/ChatSection.tsx
Normal file
205
ui-component/components/chat/ChatSection.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { showAlert } from '@/components/ui/alert-system';
|
||||
import ChatOptionsDialog from './ChatOptionsDialog';
|
||||
import ChatMessageComponent from './ChatMessage';
|
||||
|
||||
import { ChatMessage, QueryOptions } from '@/components/types';
|
||||
|
||||
interface ChatSectionProps {
|
||||
apiBaseUrl: string;
|
||||
authToken: string | null;
|
||||
}
|
||||
|
||||
const ChatSection: React.FC<ChatSectionProps> = ({ apiBaseUrl, authToken }) => {
|
||||
const [chatQuery, setChatQuery] = useState('');
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showChatAdvanced, setShowChatAdvanced] = useState(false);
|
||||
const [availableGraphs, setAvailableGraphs] = useState<string[]>([]);
|
||||
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
|
||||
filters: '{}',
|
||||
k: 4,
|
||||
min_score: 0,
|
||||
use_reranking: false,
|
||||
use_colpali: true,
|
||||
max_tokens: 500,
|
||||
temperature: 0.7
|
||||
});
|
||||
|
||||
// Update query options
|
||||
const updateQueryOption = <K extends keyof QueryOptions>(key: K, value: QueryOptions[K]) => {
|
||||
setQueryOptions(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// Fetch available graphs for dropdown
|
||||
const fetchGraphs = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/graphs`, {
|
||||
headers: {
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : ''
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch graphs: ${response.statusText}`);
|
||||
}
|
||||
const graphsData = await response.json();
|
||||
setAvailableGraphs(graphsData.map((graph: { name: string }) => graph.name));
|
||||
} catch (err) {
|
||||
console.error('Error fetching available graphs:', err);
|
||||
}
|
||||
}, [apiBaseUrl, authToken]);
|
||||
|
||||
// Fetch graphs when auth token or API URL changes
|
||||
useEffect(() => {
|
||||
if (authToken) {
|
||||
console.log('ChatSection: Fetching graphs with new auth token');
|
||||
// Clear current messages when auth changes
|
||||
setChatMessages([]);
|
||||
fetchGraphs();
|
||||
}
|
||||
}, [authToken, apiBaseUrl, fetchGraphs]);
|
||||
|
||||
// Handle chat
|
||||
const handleChat = async () => {
|
||||
if (!chatQuery.trim()) {
|
||||
showAlert('Please enter a message', {
|
||||
type: 'error',
|
||||
duration: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Add user message to chat
|
||||
const userMessage: ChatMessage = { role: 'user', content: chatQuery };
|
||||
setChatMessages(prev => [...prev, userMessage]);
|
||||
|
||||
// Prepare options with graph_name if it exists
|
||||
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
|
||||
};
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/query`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : '',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: chatQuery,
|
||||
...options
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Query failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Add assistant response to chat
|
||||
const assistantMessage: ChatMessage = { role: 'assistant', content: data.completion };
|
||||
setChatMessages(prev => [...prev, assistantMessage]);
|
||||
setChatQuery(''); // Clear input
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Chat Query Failed',
|
||||
duration: 5000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-[calc(100vh-12rem)] flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Chat with Your Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Ask questions about your documents and get AI-powered answers.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow overflow-hidden flex flex-col">
|
||||
<ScrollArea className="flex-grow pr-4 mb-4">
|
||||
{chatMessages.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{chatMessages.map((message, index) => (
|
||||
<ChatMessageComponent
|
||||
key={index}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<MessageSquare className="mx-auto h-12 w-12 mb-2" />
|
||||
<p>Start a conversation about your documents</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="Ask a question..."
|
||||
value={chatQuery}
|
||||
onChange={(e) => setChatQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleChat();
|
||||
}
|
||||
}}
|
||||
className="min-h-10"
|
||||
/>
|
||||
<Button onClick={handleChat} disabled={loading}>
|
||||
{loading ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press Enter to send, Shift+Enter for a new line
|
||||
</p>
|
||||
|
||||
<ChatOptionsDialog
|
||||
showChatAdvanced={showChatAdvanced}
|
||||
setShowChatAdvanced={setShowChatAdvanced}
|
||||
queryOptions={queryOptions}
|
||||
updateQueryOption={updateQueryOption}
|
||||
availableGraphs={availableGraphs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatSection;
|
125
ui-component/components/documents/DocumentDetail.tsx
Normal file
125
ui-component/components/documents/DocumentDetail.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { Document } from '@/components/types';
|
||||
|
||||
interface DocumentDetailProps {
|
||||
selectedDocument: Document | null;
|
||||
handleDeleteDocument: (documentId: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const DocumentDetail: React.FC<DocumentDetailProps> = ({
|
||||
selectedDocument,
|
||||
handleDeleteDocument,
|
||||
loading
|
||||
}) => {
|
||||
if (!selectedDocument) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-200px)] flex items-center justify-center p-8 border border-dashed rounded-lg">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Info className="mx-auto h-12 w-12 mb-2" />
|
||||
<p>Select a document to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg">
|
||||
<div className="bg-muted px-4 py-3 border-b sticky top-0">
|
||||
<h3 className="text-lg font-semibold">Document Details</h3>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-200px)]">
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Filename</h3>
|
||||
<p>{selectedDocument.filename || 'N/A'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Content Type</h3>
|
||||
<Badge>{selectedDocument.content_type}</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Document ID</h3>
|
||||
<p className="font-mono text-xs">{selectedDocument.external_id}</p>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="metadata">
|
||||
<AccordionTrigger>Metadata</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(selectedDocument.metadata, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="system-metadata">
|
||||
<AccordionTrigger>System Metadata</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(selectedDocument.system_metadata, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="additional-metadata">
|
||||
<AccordionTrigger>Additional Metadata</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(selectedDocument.additional_metadata, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="pt-4 border-t mt-4">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full border-red-500 text-red-500 hover:bg-red-100 dark:hover:bg-red-950">
|
||||
Delete Document
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Document</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this document? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-3">
|
||||
<p className="font-medium">Document: {selectedDocument.filename || selectedDocument.external_id}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">ID: {selectedDocument.external_id}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => (document.querySelector('[data-state="open"] button[data-state="closed"]') as HTMLElement)?.click()}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-500 hover:bg-red-100 dark:hover:bg-red-950"
|
||||
onClick={() => handleDeleteDocument(selectedDocument.external_id)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentDetail;
|
96
ui-component/components/documents/DocumentList.tsx
Normal file
96
ui-component/components/documents/DocumentList.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
import { Document } from '@/components/types';
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Document[];
|
||||
selectedDocument: Document | null;
|
||||
selectedDocuments: string[];
|
||||
handleDocumentClick: (document: Document) => void;
|
||||
handleCheckboxChange: (checked: boolean | "indeterminate", docId: string) => void;
|
||||
getSelectAllState: () => boolean | "indeterminate";
|
||||
setSelectedDocuments: (docIds: string[]) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const DocumentList: React.FC<DocumentListProps> = ({
|
||||
documents,
|
||||
selectedDocument,
|
||||
selectedDocuments,
|
||||
handleDocumentClick,
|
||||
handleCheckboxChange,
|
||||
getSelectAllState,
|
||||
setSelectedDocuments,
|
||||
loading
|
||||
}) => {
|
||||
if (loading && !documents.length) {
|
||||
return <div className="text-center py-8 flex-1">Loading documents...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md">
|
||||
<div className="bg-muted border-b p-3 font-medium sticky top-0">
|
||||
<div className="grid grid-cols-12">
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<Checkbox
|
||||
id="select-all-documents"
|
||||
checked={getSelectAllState()}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedDocuments(documents.map(doc => doc.external_id));
|
||||
} else {
|
||||
setSelectedDocuments([]);
|
||||
}
|
||||
}}
|
||||
aria-label="Select all documents"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4">Filename</div>
|
||||
<div className="col-span-3">Type</div>
|
||||
<div className="col-span-4">ID</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-200px)]">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.external_id}
|
||||
onClick={() => handleDocumentClick(doc)}
|
||||
className="grid grid-cols-12 p-3 cursor-pointer hover:bg-muted/50 border-b"
|
||||
>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<Checkbox
|
||||
id={`doc-${doc.external_id}`}
|
||||
checked={selectedDocuments.includes(doc.external_id)}
|
||||
onCheckedChange={(checked) => handleCheckboxChange(checked, doc.external_id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${doc.filename || 'document'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 flex items-center">
|
||||
{doc.filename || 'N/A'}
|
||||
{doc.external_id === selectedDocument?.external_id && (
|
||||
<Badge variant="outline" className="ml-2">Selected</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Badge variant="secondary">
|
||||
{doc.content_type.split('/')[0]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="col-span-4 font-mono text-xs">
|
||||
{doc.external_id.substring(0, 8)}...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentList;
|
655
ui-component/components/documents/DocumentsSection.tsx
Normal file
655
ui-component/components/documents/DocumentsSection.tsx
Normal file
@ -0,0 +1,655 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { showAlert, removeAlert } from '@/components/ui/alert-system';
|
||||
import DocumentList from './DocumentList';
|
||||
import DocumentDetail from './DocumentDetail';
|
||||
import { UploadDialog, useUploadDialog } from './UploadDialog';
|
||||
|
||||
import { Document } from '@/components/types';
|
||||
|
||||
interface DocumentsSectionProps {
|
||||
apiBaseUrl: string;
|
||||
authToken: string | null;
|
||||
}
|
||||
|
||||
const DocumentsSection: React.FC<DocumentsSectionProps> = ({ apiBaseUrl, authToken }) => {
|
||||
// Ensure apiBaseUrl is correctly formatted, especially for localhost
|
||||
const effectiveApiUrl = React.useMemo(() => {
|
||||
console.log('DocumentsSection: Input apiBaseUrl:', apiBaseUrl);
|
||||
// Check if it's a localhost URL and ensure it has the right format
|
||||
if (apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')) {
|
||||
if (!apiBaseUrl.includes('http')) {
|
||||
return `http://${apiBaseUrl}`;
|
||||
}
|
||||
}
|
||||
return apiBaseUrl;
|
||||
}, [apiBaseUrl]);
|
||||
// State for documents
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Upload dialog state from custom hook
|
||||
const uploadDialogState = useUploadDialog();
|
||||
// Extract only the state variables we actually use in this component
|
||||
const {
|
||||
showUploadDialog,
|
||||
setShowUploadDialog,
|
||||
metadata,
|
||||
rules,
|
||||
useColpali,
|
||||
resetUploadDialog
|
||||
} = uploadDialogState;
|
||||
|
||||
// Headers for API requests - ensure this updates when props change
|
||||
const headers = React.useMemo(() => {
|
||||
return {
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : ''
|
||||
};
|
||||
}, [authToken]);
|
||||
|
||||
// Fetch all documents
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
// Only set loading state for initial load, not for refreshes
|
||||
if (documents.length === 0) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
console.log('DocumentsSection: Sending request to:', `${effectiveApiUrl}/documents`);
|
||||
console.log('DocumentsSection: Headers:', JSON.stringify(headers));
|
||||
|
||||
// Use non-blocking fetch
|
||||
fetch(`${effectiveApiUrl}/documents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {})
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch documents: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
setDocuments(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
duration: 5000
|
||||
});
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
duration: 5000
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [effectiveApiUrl, authToken, headers, documents.length]);
|
||||
|
||||
// Fetch documents when auth token or API URL changes (but not when fetchDocuments changes)
|
||||
useEffect(() => {
|
||||
if (authToken || effectiveApiUrl.includes('localhost')) {
|
||||
console.log('DocumentsSection: Fetching documents on auth/API change');
|
||||
|
||||
// Clear current documents and reset state
|
||||
setDocuments([]);
|
||||
setSelectedDocument(null);
|
||||
fetchDocuments();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authToken, effectiveApiUrl]);
|
||||
|
||||
// Fetch a specific document by ID
|
||||
const fetchDocument = async (documentId: string) => {
|
||||
try {
|
||||
console.log('DocumentsSection: Fetching document detail from:', `${effectiveApiUrl}/documents/${documentId}`);
|
||||
|
||||
// Use non-blocking fetch to avoid locking the UI
|
||||
fetch(`${effectiveApiUrl}/documents/${documentId}`, {
|
||||
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch document: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
setSelectedDocument(data);
|
||||
})
|
||||
.catch(err => {
|
||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
duration: 5000
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle document click
|
||||
const handleDocumentClick = (document: Document) => {
|
||||
fetchDocument(document.external_id);
|
||||
};
|
||||
|
||||
// Helper function for document deletion API call
|
||||
const deleteDocumentApi = async (documentId: string) => {
|
||||
const response = await fetch(`${effectiveApiUrl}/documents/${documentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete document: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// Handle single document deletion
|
||||
const handleDeleteDocument = async (documentId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
console.log('DocumentsSection: Deleting document:', documentId);
|
||||
|
||||
await deleteDocumentApi(documentId);
|
||||
|
||||
// Clear selected document if it was the one deleted
|
||||
if (selectedDocument?.external_id === documentId) {
|
||||
setSelectedDocument(null);
|
||||
}
|
||||
|
||||
// Refresh documents list
|
||||
await fetchDocuments();
|
||||
|
||||
// Show success message
|
||||
showAlert("Document deleted successfully", {
|
||||
type: "success",
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Delete Failed',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Also remove the progress alert if there was an error
|
||||
removeAlert('delete-multiple-progress');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle multiple document deletion
|
||||
const handleDeleteMultipleDocuments = async () => {
|
||||
if (selectedDocuments.length === 0) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Show initial alert for deletion progress
|
||||
const alertId = 'delete-multiple-progress';
|
||||
showAlert(`Deleting ${selectedDocuments.length} documents...`, {
|
||||
type: 'info',
|
||||
dismissible: false,
|
||||
id: alertId
|
||||
});
|
||||
|
||||
console.log('DocumentsSection: Deleting multiple documents:', selectedDocuments);
|
||||
|
||||
// Perform deletions in parallel
|
||||
const results = await Promise.all(
|
||||
selectedDocuments.map(docId => deleteDocumentApi(docId))
|
||||
);
|
||||
|
||||
// Check if any deletion failed
|
||||
const failedCount = results.filter(res => !res.ok).length;
|
||||
|
||||
// Clear selected document if it was among deleted ones
|
||||
if (selectedDocument && selectedDocuments.includes(selectedDocument.external_id)) {
|
||||
setSelectedDocument(null);
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
setSelectedDocuments([]);
|
||||
|
||||
// Refresh documents list
|
||||
await fetchDocuments();
|
||||
|
||||
// Remove progress alert
|
||||
removeAlert(alertId);
|
||||
|
||||
// Show final result alert
|
||||
if (failedCount > 0) {
|
||||
showAlert(`Deleted ${selectedDocuments.length - failedCount} documents. ${failedCount} deletions failed.`, {
|
||||
type: "warning",
|
||||
duration: 4000
|
||||
});
|
||||
} else {
|
||||
showAlert(`Successfully deleted ${selectedDocuments.length} documents`, {
|
||||
type: "success",
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Delete Failed',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Also remove the progress alert if there was an error
|
||||
removeAlert('delete-multiple-progress');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle checkbox change (wrapper function for use with shadcn checkbox)
|
||||
const handleCheckboxChange = (checked: boolean | "indeterminate", docId: string) => {
|
||||
setSelectedDocuments(prev => {
|
||||
if (checked === true && !prev.includes(docId)) {
|
||||
return [...prev, docId];
|
||||
} else if (checked === false && prev.includes(docId)) {
|
||||
return prev.filter(id => id !== docId);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get "indeterminate" state for select all checkbox
|
||||
const getSelectAllState = () => {
|
||||
if (selectedDocuments.length === 0) return false;
|
||||
if (selectedDocuments.length === documents.length) return true;
|
||||
return "indeterminate";
|
||||
};
|
||||
|
||||
// Handle file upload
|
||||
const handleFileUpload = async (file: File | null) => {
|
||||
if (!file) {
|
||||
showAlert('Please select a file to upload', {
|
||||
type: 'error',
|
||||
duration: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog and update upload count using alert system
|
||||
setShowUploadDialog(false);
|
||||
const uploadId = 'upload-progress';
|
||||
showAlert(`Uploading 1 file...`, {
|
||||
type: 'upload',
|
||||
dismissible: false,
|
||||
id: uploadId
|
||||
});
|
||||
|
||||
// Save file reference before we reset the form
|
||||
const fileToUploadRef = file;
|
||||
const metadataRef = metadata;
|
||||
const rulesRef = rules;
|
||||
const useColpaliRef = useColpali;
|
||||
|
||||
// Reset form
|
||||
resetUploadDialog();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileToUploadRef);
|
||||
formData.append('metadata', metadataRef);
|
||||
formData.append('rules', rulesRef);
|
||||
|
||||
const url = `${effectiveApiUrl}/ingest/file${useColpaliRef ? '?use_colpali=true' : ''}`;
|
||||
|
||||
// Non-blocking fetch
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
fetchDocuments(); // Refresh document list (non-blocking)
|
||||
|
||||
// Show success message and remove upload progress
|
||||
showAlert(`File uploaded successfully!`, {
|
||||
type: 'success',
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
// Remove the upload alert
|
||||
removeAlert('upload-progress');
|
||||
})
|
||||
.catch(err => {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
const errorMsg = `Error uploading ${fileToUploadRef.name}: ${errorMessage}`;
|
||||
|
||||
// Show error alert and remove upload progress
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Upload Failed',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Remove the upload alert
|
||||
removeAlert('upload-progress');
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
const errorMsg = `Error uploading ${fileToUploadRef.name}: ${errorMessage}`;
|
||||
|
||||
// Show error alert
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Upload Failed',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Remove the upload progress alert
|
||||
removeAlert('upload-progress');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle batch file upload
|
||||
const handleBatchFileUpload = async (files: File[]) => {
|
||||
if (files.length === 0) {
|
||||
showAlert('Please select files to upload', {
|
||||
type: 'error',
|
||||
duration: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog and update upload count using alert system
|
||||
setShowUploadDialog(false);
|
||||
const fileCount = files.length;
|
||||
const uploadId = 'batch-upload-progress';
|
||||
showAlert(`Uploading ${fileCount} files...`, {
|
||||
type: 'upload',
|
||||
dismissible: false,
|
||||
id: uploadId
|
||||
});
|
||||
|
||||
// Save form data locally before resetting
|
||||
const batchFilesRef = [...files];
|
||||
const metadataRef = metadata;
|
||||
const rulesRef = rules;
|
||||
const useColpaliRef = useColpali;
|
||||
|
||||
// Reset form immediately
|
||||
resetUploadDialog();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Append each file to the formData with the same field name
|
||||
batchFilesRef.forEach(file => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
formData.append('metadata', metadataRef);
|
||||
formData.append('rules', rulesRef);
|
||||
formData.append('parallel', 'true');
|
||||
if (useColpaliRef !== undefined) {
|
||||
formData.append('use_colpali', useColpaliRef.toString());
|
||||
}
|
||||
|
||||
// Non-blocking fetch
|
||||
fetch(`${effectiveApiUrl}/ingest/files`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(result => {
|
||||
fetchDocuments(); // Refresh document list (non-blocking)
|
||||
|
||||
// If there are errors, show them in the error alert
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
const errorMsg = `${result.errors.length} of ${fileCount} files failed to upload`;
|
||||
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Upload Partially Failed',
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
// Show success message
|
||||
showAlert(`${fileCount} files uploaded successfully!`, {
|
||||
type: 'success',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the upload alert
|
||||
removeAlert('batch-upload-progress');
|
||||
})
|
||||
.catch(err => {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
const errorMsg = `Error uploading files: ${errorMessage}`;
|
||||
|
||||
// Show error alert
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Upload Failed',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Remove the upload alert
|
||||
removeAlert('batch-upload-progress');
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
const errorMsg = `Error uploading files: ${errorMessage}`;
|
||||
|
||||
// Show error alert
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Upload Failed',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Remove the upload progress alert
|
||||
removeAlert('batch-upload-progress');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle text upload
|
||||
const handleTextUpload = async (text: string, meta: string, rulesText: string, useColpaliFlag: boolean) => {
|
||||
if (!text.trim()) {
|
||||
showAlert('Please enter text content', {
|
||||
type: 'error',
|
||||
duration: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog and update upload count using alert system
|
||||
setShowUploadDialog(false);
|
||||
const uploadId = 'text-upload-progress';
|
||||
showAlert(`Uploading text document...`, {
|
||||
type: 'upload',
|
||||
dismissible: false,
|
||||
id: uploadId
|
||||
});
|
||||
|
||||
// Save content before resetting
|
||||
const textContentRef = text;
|
||||
const metadataRef = meta;
|
||||
const rulesRef = rulesText;
|
||||
const useColpaliRef = useColpaliFlag;
|
||||
|
||||
// Reset form immediately
|
||||
resetUploadDialog();
|
||||
|
||||
try {
|
||||
// Non-blocking fetch
|
||||
fetch(`${effectiveApiUrl}/ingest/text`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : '',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: textContentRef,
|
||||
metadata: JSON.parse(metadataRef || '{}'),
|
||||
rules: JSON.parse(rulesRef || '[]'),
|
||||
use_colpali: useColpaliRef
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
fetchDocuments(); // Refresh document list (non-blocking)
|
||||
|
||||
// Show success message
|
||||
showAlert(`Text document uploaded successfully!`, {
|
||||
type: 'success',
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
// Remove the upload alert
|
||||
removeAlert('text-upload-progress');
|
||||
})
|
||||
.catch(err => {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
const errorMsg = `Error uploading text: ${errorMessage}`;
|
||||
|
||||
// Show error alert
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Upload Failed',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Remove the upload alert
|
||||
removeAlert('text-upload-progress');
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
const errorMsg = `Error uploading text: ${errorMessage}`;
|
||||
|
||||
// Show error alert
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Upload Failed',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Remove the upload progress alert
|
||||
removeAlert('text-upload-progress');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
<div className="flex justify-between items-center py-3 mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-tight">Your Documents</h2>
|
||||
<p className="text-muted-foreground">Manage your uploaded documents and view their metadata.</p>
|
||||
</div>
|
||||
{selectedDocuments.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDeleteMultipleDocuments}
|
||||
disabled={loading}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50 ml-4"
|
||||
>
|
||||
Delete {selectedDocuments.length} selected
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<UploadDialog
|
||||
showUploadDialog={showUploadDialog}
|
||||
setShowUploadDialog={setShowUploadDialog}
|
||||
loading={loading}
|
||||
onFileUpload={handleFileUpload}
|
||||
onBatchFileUpload={handleBatchFileUpload}
|
||||
onTextUpload={handleTextUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{documents.length === 0 && !loading ? (
|
||||
<div className="text-center py-8 border border-dashed rounded-lg flex-1 flex items-center justify-center">
|
||||
<div>
|
||||
<Upload className="mx-auto h-12 w-12 mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No documents found. Upload your first document.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col md:flex-row gap-4 flex-1">
|
||||
<div className="w-full md:w-2/3">
|
||||
<DocumentList
|
||||
documents={documents}
|
||||
selectedDocument={selectedDocument}
|
||||
selectedDocuments={selectedDocuments}
|
||||
handleDocumentClick={handleDocumentClick}
|
||||
handleCheckboxChange={handleCheckboxChange}
|
||||
getSelectAllState={getSelectAllState}
|
||||
setSelectedDocuments={setSelectedDocuments}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/3">
|
||||
<DocumentDetail
|
||||
selectedDocument={selectedDocument}
|
||||
handleDeleteDocument={handleDeleteDocument}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsSection;
|
268
ui-component/components/documents/UploadDialog.tsx
Normal file
268
ui-component/components/documents/UploadDialog.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, ChangeEvent } 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 { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Upload } from 'lucide-react';
|
||||
// Alert system is handled by the parent component
|
||||
|
||||
interface UploadDialogProps {
|
||||
showUploadDialog: boolean;
|
||||
setShowUploadDialog: (show: boolean) => void;
|
||||
loading: boolean;
|
||||
onFileUpload: (file: File | null) => Promise<void>;
|
||||
onBatchFileUpload: (files: File[]) => Promise<void>;
|
||||
onTextUpload: (text: string, metadata: string, rules: string, useColpali: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const UploadDialog: React.FC<UploadDialogProps> = ({
|
||||
showUploadDialog,
|
||||
setShowUploadDialog,
|
||||
loading,
|
||||
onFileUpload,
|
||||
onBatchFileUpload,
|
||||
onTextUpload
|
||||
}) => {
|
||||
// Component state for managing the upload form
|
||||
const [uploadType, setUploadType] = useState<'file' | 'text' | 'batch'>('file');
|
||||
const [textContent, setTextContent] = useState('');
|
||||
// Used in handleFileChange and for providing to the parent component
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const [batchFilesToUpload, setBatchFilesToUpload] = useState<File[]>([]);
|
||||
const [metadata, setMetadata] = useState('{}');
|
||||
const [rules, setRules] = useState('[]');
|
||||
const [useColpali, setUseColpali] = useState(true);
|
||||
|
||||
// Reset upload dialog state
|
||||
const resetUploadDialog = () => {
|
||||
setUploadType('file');
|
||||
setFileToUpload(null);
|
||||
setBatchFilesToUpload([]);
|
||||
setTextContent('');
|
||||
setMetadata('{}');
|
||||
setRules('[]');
|
||||
setUseColpali(true);
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
setFileToUpload(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle batch file selection
|
||||
const handleBatchFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
setBatchFilesToUpload(Array.from(files));
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Component state flow:
|
||||
* - Internal state is managed here (uploadType, fileToUpload, etc.)
|
||||
* - Actions like file upload are handled by parent component via callbacks
|
||||
* - No need to expose getter/setter methods as the parent has its own state
|
||||
*/
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={showUploadDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowUploadDialog(open);
|
||||
if (!open) resetUploadDialog();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => setShowUploadDialog(true)}>
|
||||
<Upload className="mr-2 h-4 w-4" /> Upload Document
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Document</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a file or text to your Morphik repository.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={uploadType === 'file' ? "default" : "outline"}
|
||||
onClick={() => setUploadType('file')}
|
||||
>
|
||||
File
|
||||
</Button>
|
||||
<Button
|
||||
variant={uploadType === 'batch' ? "default" : "outline"}
|
||||
onClick={() => setUploadType('batch')}
|
||||
>
|
||||
Batch Files
|
||||
</Button>
|
||||
<Button
|
||||
variant={uploadType === 'text' ? "default" : "outline"}
|
||||
onClick={() => setUploadType('text')}
|
||||
>
|
||||
Text
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{uploadType === 'file' ? (
|
||||
<div>
|
||||
<Label htmlFor="file" className="block mb-2">File</Label>
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
) : uploadType === 'batch' ? (
|
||||
<div>
|
||||
<Label htmlFor="batchFiles" className="block mb-2">Select Multiple Files</Label>
|
||||
<Input
|
||||
id="batchFiles"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleBatchFileChange}
|
||||
/>
|
||||
{batchFilesToUpload.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-medium mb-1">{batchFilesToUpload.length} files selected:</p>
|
||||
<ScrollArea className="h-24 w-full rounded-md border p-2">
|
||||
<ul className="text-xs">
|
||||
{Array.from(batchFilesToUpload).map((file, index) => (
|
||||
<li key={index} className="py-1 border-b border-gray-100 last:border-0">
|
||||
{file.name} ({(file.size / 1024).toFixed(1)} KB)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="text" className="block mb-2">Text Content</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
placeholder="Enter text content"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="metadata" className="block mb-2">Metadata (JSON)</Label>
|
||||
<Textarea
|
||||
id="metadata"
|
||||
value={metadata}
|
||||
onChange={(e) => setMetadata(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="rules" className="block mb-2">Rules (JSON)</Label>
|
||||
<Textarea
|
||||
id="rules"
|
||||
value={rules}
|
||||
onChange={(e) => setRules(e.target.value)}
|
||||
placeholder='[{"type": "metadata_extraction", "schema": {...}}]'
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useColpali"
|
||||
checked={useColpali}
|
||||
onChange={(e) => setUseColpali(e.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="useColpali">Use Colpali</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowUploadDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (uploadType === 'file') {
|
||||
onFileUpload(fileToUpload);
|
||||
} else if (uploadType === 'batch') {
|
||||
onBatchFileUpload(batchFilesToUpload);
|
||||
} else {
|
||||
onTextUpload(textContent, metadata, rules, useColpali);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Uploading...' : 'Upload'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
UploadDialog,
|
||||
type UploadDialogProps
|
||||
};
|
||||
|
||||
// Export these values as a custom hook for easy access from the parent component
|
||||
// Custom hook to provide upload dialog state management functionality
|
||||
export const useUploadDialog = () => {
|
||||
// Define all state variables needed for the upload process
|
||||
const [uploadType, setUploadType] = useState<'file' | 'text' | 'batch'>('file');
|
||||
const [textContent, setTextContent] = useState('');
|
||||
// This state is used by the parent component during file upload process
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const [batchFilesToUpload, setBatchFilesToUpload] = useState<File[]>([]);
|
||||
const [metadata, setMetadata] = useState('{}');
|
||||
const [rules, setRules] = useState('[]');
|
||||
const [useColpali, setUseColpali] = useState(true);
|
||||
const [showUploadDialog, setShowUploadDialog] = useState(false);
|
||||
|
||||
const resetUploadDialog = () => {
|
||||
setUploadType('file');
|
||||
setFileToUpload(null);
|
||||
setBatchFilesToUpload([]);
|
||||
setTextContent('');
|
||||
setMetadata('{}');
|
||||
setRules('[]');
|
||||
setUseColpali(true);
|
||||
};
|
||||
|
||||
return {
|
||||
uploadType,
|
||||
setUploadType,
|
||||
textContent,
|
||||
setTextContent,
|
||||
fileToUpload,
|
||||
setFileToUpload,
|
||||
batchFilesToUpload,
|
||||
setBatchFilesToUpload,
|
||||
metadata,
|
||||
setMetadata,
|
||||
rules,
|
||||
setRules,
|
||||
useColpali,
|
||||
setUseColpali,
|
||||
showUploadDialog,
|
||||
setShowUploadDialog,
|
||||
resetUploadDialog
|
||||
};
|
||||
};
|
27
ui-component/components/mode-toggle.tsx
Normal file
27
ui-component/components/mode-toggle.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
title="Toggle theme"
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
110
ui-component/components/search/SearchOptionsDialog.tsx
Normal file
110
ui-component/components/search/SearchOptionsDialog.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
"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 { SearchOptions } from '@/components/types';
|
||||
|
||||
interface SearchOptionsDialogProps {
|
||||
showSearchAdvanced: boolean;
|
||||
setShowSearchAdvanced: (show: boolean) => void;
|
||||
searchOptions: SearchOptions;
|
||||
updateSearchOption: <K extends keyof SearchOptions>(key: K, value: SearchOptions[K]) => void;
|
||||
}
|
||||
|
||||
const SearchOptionsDialog: React.FC<SearchOptionsDialogProps> = ({
|
||||
showSearchAdvanced,
|
||||
setShowSearchAdvanced,
|
||||
searchOptions,
|
||||
updateSearchOption
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={showSearchAdvanced} onOpenChange={setShowSearchAdvanced}>
|
||||
<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>Search Options</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure advanced search parameters
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="search-filters" className="block mb-2">Filters (JSON)</Label>
|
||||
<Textarea
|
||||
id="search-filters"
|
||||
value={searchOptions.filters}
|
||||
onChange={(e) => updateSearchOption('filters', e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="search-k" className="block mb-2">
|
||||
Number of Results (k): {searchOptions.k}
|
||||
</Label>
|
||||
<Input
|
||||
id="search-k"
|
||||
type="number"
|
||||
min={1}
|
||||
value={searchOptions.k}
|
||||
onChange={(e) => updateSearchOption('k', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="search-min-score" className="block mb-2">
|
||||
Minimum Score: {searchOptions.min_score.toFixed(2)}
|
||||
</Label>
|
||||
<Input
|
||||
id="search-min-score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={searchOptions.min_score}
|
||||
onChange={(e) => updateSearchOption('min_score', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="search-reranking">Use Reranking</Label>
|
||||
<Switch
|
||||
id="search-reranking"
|
||||
checked={searchOptions.use_reranking}
|
||||
onCheckedChange={(checked) => updateSearchOption('use_reranking', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="search-colpali">Use Colpali</Label>
|
||||
<Switch
|
||||
id="search-colpali"
|
||||
checked={searchOptions.use_colpali}
|
||||
onCheckedChange={(checked) => updateSearchOption('use_colpali', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowSearchAdvanced(false)}>Apply</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchOptionsDialog;
|
86
ui-component/components/search/SearchResultCard.tsx
Normal file
86
ui-component/components/search/SearchResultCard.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
|
||||
import { SearchResult } from '@/components/types';
|
||||
|
||||
interface SearchResultCardProps {
|
||||
result: SearchResult;
|
||||
}
|
||||
|
||||
const SearchResultCard: React.FC<SearchResultCardProps> = ({ result }) => {
|
||||
// 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 (
|
||||
<Card key={`${result.document_id}-${result.chunk_number}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{result.filename || `Document ${result.document_id.substring(0, 8)}...`}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Chunk {result.chunk_number} • Score: {result.score.toFixed(2)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{result.content_type}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{renderContent(result.content, result.content_type)}
|
||||
|
||||
<Accordion type="single" collapsible className="mt-4">
|
||||
<AccordionItem value="metadata">
|
||||
<AccordionTrigger className="text-sm">Metadata</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(result.metadata, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResultCard;
|
163
ui-component/components/search/SearchSection.tsx
Normal file
163
ui-component/components/search/SearchSection.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Search } from 'lucide-react';
|
||||
import { showAlert } from '@/components/ui/alert-system';
|
||||
import SearchOptionsDialog from './SearchOptionsDialog';
|
||||
import SearchResultCard from './SearchResultCard';
|
||||
|
||||
import { SearchResult, SearchOptions } from '@/components/types';
|
||||
|
||||
interface SearchSectionProps {
|
||||
apiBaseUrl: string;
|
||||
authToken: string | null;
|
||||
}
|
||||
|
||||
const SearchSection: React.FC<SearchSectionProps> = ({ apiBaseUrl, authToken }) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSearchAdvanced, setShowSearchAdvanced] = useState(false);
|
||||
const [searchOptions, setSearchOptions] = useState<SearchOptions>({
|
||||
filters: '{}',
|
||||
k: 4,
|
||||
min_score: 0,
|
||||
use_reranking: false,
|
||||
use_colpali: true
|
||||
});
|
||||
|
||||
// Update search options
|
||||
const updateSearchOption = <K extends keyof SearchOptions>(key: K, value: SearchOptions[K]) => {
|
||||
setSearchOptions(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// Reset search results when auth token or API URL changes
|
||||
useEffect(() => {
|
||||
console.log('SearchSection: Token or API URL changed, resetting results');
|
||||
setSearchResults([]);
|
||||
}, [authToken, apiBaseUrl]);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
showAlert('Please enter a search query', {
|
||||
type: 'error',
|
||||
duration: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/retrieve/chunks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : '',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: searchQuery,
|
||||
filters: JSON.parse(searchOptions.filters || '{}'),
|
||||
k: searchOptions.k,
|
||||
min_score: searchOptions.min_score,
|
||||
use_reranking: searchOptions.use_reranking,
|
||||
use_colpali: searchOptions.use_colpali
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSearchResults(data);
|
||||
|
||||
if (data.length === 0) {
|
||||
showAlert("No search results found for the query", {
|
||||
type: "info",
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
showAlert(errorMsg, {
|
||||
type: 'error',
|
||||
title: 'Search Failed',
|
||||
duration: 5000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex-1 flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Search Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Search across your documents to find relevant information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter search query"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SearchOptionsDialog
|
||||
showSearchAdvanced={showSearchAdvanced}
|
||||
setShowSearchAdvanced={setShowSearchAdvanced}
|
||||
searchOptions={searchOptions}
|
||||
updateSearchOption={updateSearchOption}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex-1 overflow-hidden">
|
||||
{searchResults.length > 0 ? (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">Results ({searchResults.length})</h3>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-320px)]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultCard key={`${result.document_id}-${result.chunk_number}`} result={result} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 border border-dashed rounded-lg">
|
||||
<Search className="mx-auto h-12 w-12 mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery.trim() ? 'No results found. Try a different query.' : 'Enter a query to search your documents.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchSection;
|
11
ui-component/components/theme-provider.tsx
Normal file
11
ui-component/components/theme-provider.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
48
ui-component/components/types.ts
Normal file
48
ui-component/components/types.ts
Normal file
@ -0,0 +1,48 @@
|
||||
// Common types used across multiple components
|
||||
|
||||
export interface MorphikUIProps {
|
||||
connectionUri?: string;
|
||||
apiBaseUrl?: string;
|
||||
isReadOnlyUri?: boolean; // Controls whether the URI can be edited
|
||||
onUriChange?: (uri: string) => void; // Callback when URI is changed
|
||||
onBackClick?: () => void; // Callback when back button is clicked
|
||||
appName?: string; // Name of the app to display in UI
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
external_id: string;
|
||||
filename?: string;
|
||||
content_type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
system_metadata: Record<string, unknown>;
|
||||
additional_metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
document_id: string;
|
||||
chunk_number: number;
|
||||
content: string;
|
||||
content_type: string;
|
||||
score: number;
|
||||
filename?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
filters: string;
|
||||
k: number;
|
||||
min_score: number;
|
||||
use_reranking: boolean;
|
||||
use_colpali: boolean;
|
||||
}
|
||||
|
||||
export interface QueryOptions extends SearchOptions {
|
||||
max_tokens: number;
|
||||
temperature: number;
|
||||
graph_name?: string;
|
||||
}
|
201
ui-component/components/ui/dropdown-menu.tsx
Normal file
201
ui-component/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
28
ui-component/components/ui/progress.tsx
Normal file
28
ui-component/components/ui/progress.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
44
ui-component/components/ui/radio-group.tsx
Normal file
44
ui-component/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
159
ui-component/components/ui/select.tsx
Normal file
159
ui-component/components/ui/select.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
@ -4,15 +4,95 @@ import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { FileText, Search, MessageSquare, ChevronLeft, ChevronRight, Network, Book } from "lucide-react"
|
||||
import { FileText, Search, MessageSquare, ChevronLeft, ChevronRight, Network, Copy, Check } from "lucide-react"
|
||||
import { ModeToggle } from "@/components/mode-toggle"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
activeSection: string
|
||||
onSectionChange: (section: string) => void
|
||||
connectionUri?: string
|
||||
isReadOnlyUri?: boolean
|
||||
onUriChange?: (uri: string) => void
|
||||
}
|
||||
|
||||
export function Sidebar({ className, activeSection, onSectionChange, ...props }: SidebarProps) {
|
||||
export function Sidebar({
|
||||
className,
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
connectionUri,
|
||||
isReadOnlyUri = false,
|
||||
onUriChange,
|
||||
...props
|
||||
}: SidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(false)
|
||||
const [editableUri, setEditableUri] = React.useState('')
|
||||
const [isEditingUri, setIsEditingUri] = React.useState(false)
|
||||
|
||||
// Initialize from localStorage or props
|
||||
React.useEffect(() => {
|
||||
// For development/testing - check if we have a stored URI
|
||||
const storedUri = typeof window !== 'undefined' ? localStorage.getItem('morphik_uri') : null;
|
||||
|
||||
if (storedUri) {
|
||||
setEditableUri(storedUri);
|
||||
// Note: we're removing the auto-notification to avoid refresh loops
|
||||
} else if (connectionUri) {
|
||||
setEditableUri(connectionUri);
|
||||
}
|
||||
}, [connectionUri])
|
||||
|
||||
// Update editable URI when connectionUri changes
|
||||
React.useEffect(() => {
|
||||
if (connectionUri && connectionUri !== editableUri) {
|
||||
setEditableUri(connectionUri);
|
||||
}
|
||||
}, [connectionUri, editableUri])
|
||||
|
||||
// Extract connection details if URI is provided
|
||||
const isConnected = !!connectionUri;
|
||||
let connectionHost = null;
|
||||
try {
|
||||
if (connectionUri) {
|
||||
// Try to extract host from morphik:// format
|
||||
const match = connectionUri.match(/^morphik:\/\/[^@]+@(.+)/);
|
||||
if (match && match[1]) {
|
||||
connectionHost = match[1];
|
||||
// If it includes a protocol, remove it to get just the host
|
||||
if (connectionHost.includes('://')) {
|
||||
connectionHost = new URL(connectionHost).host;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing connection URI:', error);
|
||||
connectionHost = 'localhost';
|
||||
}
|
||||
|
||||
// Handle saving the connection URI
|
||||
const handleSaveUri = () => {
|
||||
// Store the URI in localStorage for persistence
|
||||
if (typeof window !== 'undefined') {
|
||||
if (editableUri.trim() === '') {
|
||||
// If URI is empty, remove from localStorage to default to local
|
||||
localStorage.removeItem('morphik_uri');
|
||||
} else {
|
||||
localStorage.setItem('morphik_uri', editableUri);
|
||||
}
|
||||
}
|
||||
|
||||
// Call the onUriChange callback if provided
|
||||
if (onUriChange) {
|
||||
// Pass empty string to trigger default localhost connection
|
||||
onUriChange(editableUri.trim());
|
||||
} else {
|
||||
// Fallback for demo purposes if no callback is provided
|
||||
console.log('New URI:', editableUri || '(empty - using localhost)');
|
||||
}
|
||||
|
||||
// Close editing mode
|
||||
setIsEditingUri(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -23,16 +103,135 @@ export function Sidebar({ className, activeSection, onSectionChange, ...props }:
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
{!isCollapsed && <h2 className="text-lg font-semibold">Morphik</h2>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
<div className="flex flex-col border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
{!isCollapsed && <h2 className="text-lg font-semibold">Morphik</h2>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Display connection information when not collapsed */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="p-2 bg-muted rounded-md text-xs">
|
||||
<div className="font-medium mb-1">
|
||||
{isConnected ? "Connected to:" : "Connection Status:"}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{isConnected && connectionHost && !connectionHost.includes('localhost') && !connectionHost.includes('local')
|
||||
? <span className="truncate">{connectionHost}</span>
|
||||
: <span className="flex items-center">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 mr-1.5"></span>
|
||||
Local Connection (localhost:8000)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Connection URI Section */}
|
||||
<div className="flex flex-col mt-2 pt-2 border-t border-background">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">Connection URI:</div>
|
||||
{isConnected && !isEditingUri && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
if (connectionUri) {
|
||||
navigator.clipboard.writeText(connectionUri);
|
||||
// Use the showAlert helper instead of native alert
|
||||
const event = new CustomEvent('morphik:alert', {
|
||||
detail: {
|
||||
type: 'success',
|
||||
title: 'Copied!',
|
||||
message: 'Connection URI copied to clipboard',
|
||||
duration: 3000,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}}
|
||||
title="Copy connection URI"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Add Edit button if not editing and not in read-only mode */}
|
||||
{!isReadOnlyUri && !isEditingUri && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs px-2 py-0.5 h-6"
|
||||
onClick={() => setIsEditingUri(true)}
|
||||
>
|
||||
{connectionUri ? "Edit" : "Add URI"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URI Display or Editing Area */}
|
||||
{isReadOnlyUri ? (
|
||||
// Read-only display for production/cloud environments
|
||||
<div className="mt-1 bg-background p-1 rounded text-xs font-mono break-all">
|
||||
{connectionUri ?
|
||||
// Show first and last characters with asterisks in between
|
||||
`${connectionUri.substring(0, 12)}...${connectionUri.substring(connectionUri.length - 12)}`
|
||||
: 'No URI configured'
|
||||
}
|
||||
</div>
|
||||
) : isEditingUri ? (
|
||||
// Editing mode (only available when not read-only)
|
||||
<div className="mt-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<Input
|
||||
value={editableUri}
|
||||
onChange={(e) => setEditableUri(e.target.value)}
|
||||
placeholder="morphik://token@host (empty for localhost)"
|
||||
className="h-7 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
setEditableUri('');
|
||||
handleSaveUri();
|
||||
}}
|
||||
title="Clear URI (use localhost)"
|
||||
>
|
||||
<span className="text-xs">X</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSaveUri}
|
||||
title="Save URI"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
Format: morphik://your_token@your_api_host
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Display current URI (or placeholder)
|
||||
<div className="mt-1 bg-background p-1 rounded text-xs font-mono break-all">
|
||||
{connectionUri || "Using localhost by default - click Edit to change"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
@ -73,17 +272,6 @@ export function Sidebar({ className, activeSection, onSectionChange, ...props }:
|
||||
{!isCollapsed && <span className="ml-2">Chat</span>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={activeSection === "notebooks" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start",
|
||||
isCollapsed && "justify-center"
|
||||
)}
|
||||
onClick={() => onSectionChange("notebooks")}
|
||||
>
|
||||
<Book className="h-4 w-4" />
|
||||
{!isCollapsed && <span className="ml-2">Notebooks</span>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={activeSection === "graphs" ? "secondary" : "ghost"}
|
||||
@ -98,6 +286,10 @@ export function Sidebar({ className, activeSection, onSectionChange, ...props }:
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className={cn("p-2 border-t", isCollapsed ? "flex justify-center" : "")}>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -4,3 +4,109 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts an auth token from a Morphik URI
|
||||
*
|
||||
* @param uri - The Morphik connection URI (format: morphik://appname:token@host)
|
||||
* @returns The extracted token or null if invalid
|
||||
*/
|
||||
export function extractTokenFromUri(uri: string | undefined): string | null {
|
||||
if (!uri) return null;
|
||||
|
||||
try {
|
||||
console.log('Attempting to extract token from URI:', uri);
|
||||
// The URI format is: morphik://appname:token@host
|
||||
// We need to extract just the token part (after the colon and before the @)
|
||||
|
||||
// First get everything before the @ symbol
|
||||
const beforeAt = uri.split('@')[0];
|
||||
if (!beforeAt) return null;
|
||||
|
||||
// Now extract the token after the colon
|
||||
const parts = beforeAt.split('://');
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
// Check if there's a colon in the second part, which separates app name from token
|
||||
const authPart = parts[1];
|
||||
if (authPart.includes(':')) {
|
||||
// Format is appname:token
|
||||
const fullToken = authPart.split(':')[1];
|
||||
console.log('Extracted token:', fullToken ? `${fullToken.substring(0, 5)}...` : 'null');
|
||||
return fullToken;
|
||||
} else {
|
||||
// Old format with just token (no appname:)
|
||||
console.log('Extracted token (old format):', authPart ? `${authPart.substring(0, 5)}...` : 'null');
|
||||
return authPart;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error extracting token from URI:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the host from a Morphik URI and creates an API base URL
|
||||
*
|
||||
* @param uri - The Morphik connection URI (format: morphik://appname:token@host)
|
||||
* @param defaultUrl - The default API URL to use if URI is invalid
|
||||
* @returns The API base URL derived from the URI host
|
||||
*/
|
||||
export function getApiBaseUrlFromUri(uri: string | undefined, defaultUrl: string = 'http://localhost:8000'): string {
|
||||
// If URI is empty or undefined, connect to the default URL
|
||||
if (!uri || uri.trim() === '') {
|
||||
return defaultUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
// Expected format: morphik://{token}@{host}
|
||||
const match = uri.match(/^morphik:\/\/[^@]+@(.+)/);
|
||||
if (!match || !match[1]) return defaultUrl; // Default if invalid format
|
||||
|
||||
// Get the host part
|
||||
let host = match[1];
|
||||
|
||||
// If it's local, localhost or 127.0.0.1, ensure http:// protocol and add port if needed
|
||||
if (host.includes('local') || host.includes('127.0.0.1')) {
|
||||
if (!host.includes('://')) {
|
||||
host = `http://${host}`;
|
||||
}
|
||||
// Add default port 8000 if no port specified
|
||||
if (!host.includes(':') || host.endsWith(':')) {
|
||||
host = `${host.replace(/:$/, '')}:8000`;
|
||||
}
|
||||
} else {
|
||||
// For other hosts, ensure https:// protocol
|
||||
if (!host.includes('://')) {
|
||||
host = `https://${host}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Extracted API base URL:', host);
|
||||
return host;
|
||||
} catch (err) {
|
||||
console.error("Error extracting host from URI:", err);
|
||||
return defaultUrl; // Default on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates authorization headers for API requests
|
||||
*
|
||||
* @param token - The auth token
|
||||
* @param contentType - Optional content type header
|
||||
* @returns Headers object with authorization
|
||||
*/
|
||||
export function createAuthHeaders(token: string | null, contentType?: string): HeadersInit {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
@ -1,11 +0,0 @@
|
||||
{
|
||||
"notebooks": [
|
||||
{
|
||||
"id": "nb_1743370019626",
|
||||
"name": "Example Notebook",
|
||||
"description": "This is an example notebook",
|
||||
"created_at": "2025-03-30T21:26:59.626Z"
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2025-03-30T23:40:29.108Z"
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@morphik/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"private": false,
|
||||
"description": "Modern UI component for Morphik - A powerful document processing and querying system",
|
||||
"author": "Morphik Team",
|
||||
"license": "MIT",
|
||||
@ -9,18 +9,43 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/morphik-org/morphik-core"
|
||||
},
|
||||
"keywords": [
|
||||
"morphik",
|
||||
"ui",
|
||||
"react",
|
||||
"document-processing",
|
||||
"chat",
|
||||
"search"
|
||||
],
|
||||
"homepage": "https://github.com/morphik-org/morphik-core",
|
||||
"bugs": {
|
||||
"url": "https://github.com/morphik-org/morphik-core/issues"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:package": "tsup",
|
||||
"prepublishOnly": "npm run build:package",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "^1.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.3",
|
||||
"@radix-ui/react-radio-group": "^1.2.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
@ -33,7 +58,8 @@
|
||||
"formdata-node": "^6.0.3",
|
||||
"label": "^0.2.2",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^14.2.24",
|
||||
"next": "^14",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^4.28.4",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
@ -54,7 +80,20 @@
|
||||
"eslint-config-next": "14.2.16",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
"peerDependencies": {
|
||||
"next": "^14",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"next-themes": "^0.4.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
18
ui-component/src/index.ts
Normal file
18
ui-component/src/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import MorphikUI from '../components/MorphikUI';
|
||||
import { extractTokenFromUri, getApiBaseUrlFromUri } from '../lib/utils';
|
||||
import { showAlert, showUploadAlert, removeAlert } from '../components/ui/alert-system';
|
||||
|
||||
export {
|
||||
MorphikUI,
|
||||
extractTokenFromUri,
|
||||
getApiBaseUrlFromUri,
|
||||
// Alert system helpers
|
||||
showAlert,
|
||||
showUploadAlert,
|
||||
removeAlert
|
||||
};
|
||||
|
||||
// Export types
|
||||
export type { MorphikUIProps, Document, SearchResult, ChatMessage, SearchOptions, QueryOptions } from '../components/types';
|
9
ui-component/tsconfig.lib.json
Normal file
9
ui-component/tsconfig.lib.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"incremental": false
|
||||
},
|
||||
"include": ["src/**/*", "components/**/*", "lib/**/*"]
|
||||
}
|
19
ui-component/tsup.config.ts
Normal file
19
ui-component/tsup.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
tsconfig: './tsconfig.lib.json',
|
||||
external: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'next',
|
||||
'next/image',
|
||||
'next/link',
|
||||
'@radix-ui/*',
|
||||
],
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user