UI component: dark mode, packaging, bug fixes (#86)

This commit is contained in:
Adityavardhan Agrawal 2025-04-15 16:55:47 -07:00 committed by GitHub
parent 86ec969736
commit 737039e988
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 3318 additions and 4426 deletions

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "morphik-core",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

20
ui-component/.npmignore Normal file
View 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

View 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
```

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View 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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
};
};

View 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>
)
}

View 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;

View 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;

View 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;

View 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>
}

View 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;
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View File

@ -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>
)
}

View File

@ -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;
}

View File

@ -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"
}

View File

@ -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
View 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';

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"incremental": false
},
"include": ["src/**/*", "components/**/*", "lib/**/*"]
}

View 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/*',
],
});