From 7937b3276f5258b6a3e14a43b0b07c95fa989d00 Mon Sep 17 00:00:00 2001 From: Brent Salisbury Date: Mon, 21 Oct 2024 16:34:06 -0400 Subject: [PATCH] Demo: RAG/Docling/llama-index Signed-off-by: Brent Salisbury --- .../[collectionName]/delete/route.tsx | 31 + .../[collectionName]/documents/file/route.tsx | 47 ++ .../[collectionName]/documents/url/route.tsx | 31 + .../[collectionName]/query/route.tsx | 44 ++ .../playground/ragchat/collections/route.ts | 31 + .../playground/ragchat/index-files/route.ts | 99 +++ src/app/api/playground/ragchat/query/route.ts | 107 +++ .../api/playground/ragchat/upload/route.ts | 233 +++++++ src/app/playground/ragchat/page.tsx | 619 ++++++++++++++++++ src/app/playground/ragchat/ragchat.module.css | 176 +++++ src/components/AppLayout.tsx | 3 +- 11 files changed, 1420 insertions(+), 1 deletion(-) create mode 100644 src/app/api/playground/ragchat/collections/[collectionName]/delete/route.tsx create mode 100644 src/app/api/playground/ragchat/collections/[collectionName]/documents/file/route.tsx create mode 100644 src/app/api/playground/ragchat/collections/[collectionName]/documents/url/route.tsx create mode 100644 src/app/api/playground/ragchat/collections/[collectionName]/query/route.tsx create mode 100644 src/app/api/playground/ragchat/collections/route.ts create mode 100644 src/app/api/playground/ragchat/index-files/route.ts create mode 100644 src/app/api/playground/ragchat/query/route.ts create mode 100644 src/app/api/playground/ragchat/upload/route.ts create mode 100644 src/app/playground/ragchat/page.tsx create mode 100644 src/app/playground/ragchat/ragchat.module.css diff --git a/src/app/api/playground/ragchat/collections/[collectionName]/delete/route.tsx b/src/app/api/playground/ragchat/collections/[collectionName]/delete/route.tsx new file mode 100644 index 00000000..8b8f7fd9 --- /dev/null +++ b/src/app/api/playground/ragchat/collections/[collectionName]/delete/route.tsx @@ -0,0 +1,31 @@ +'use server'; + +import { NextRequest, NextResponse } from 'next/server'; +import fetch from 'node-fetch'; + +export async function DELETE(req: NextRequest, { params }: { params: { collectionName: string } }) { + const { collectionName } = params; + + try { + console.log(`Deleting collection: ${collectionName}`); + + // Make the API request to the backend to delete the collection + const response = await fetch(`http://127.0.0.1:8000/collections/${encodeURIComponent(collectionName)}`, { + method: 'DELETE' + }); + + // Check if the response was successful + if (!response.ok) { + const errorText = await response.text(); + console.error(`Failed to delete collection: ${errorText}`); + throw new Error(`Failed to delete collection: ${errorText}`); + } + + // Return a success response to the client + console.log(`Collection ${collectionName} deleted successfully.`); + return NextResponse.json({ message: `Collection ${collectionName} deleted successfully.` }, { status: 200 }); + } catch (error: any) { + console.error('Error deleting collection:', error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/playground/ragchat/collections/[collectionName]/documents/file/route.tsx b/src/app/api/playground/ragchat/collections/[collectionName]/documents/file/route.tsx new file mode 100644 index 00000000..bb67d5d0 --- /dev/null +++ b/src/app/api/playground/ragchat/collections/[collectionName]/documents/file/route.tsx @@ -0,0 +1,47 @@ +// src/app/api/playground/ragchat/collections/[collectionName]/documents/file/route.ts +'use server'; + +import { NextRequest, NextResponse } from 'next/server'; +import fetch from 'node-fetch'; +import FormData from 'form-data'; + +export async function POST(req: NextRequest, { params }: { params: { collectionName: string } }) { + const { collectionName } = params; + + try { + // Parse the form data from the incoming request + const formData = await req.formData(); + const file = formData.get('files') as File | null; + + if (!file) { + throw new Error('File is required for upload'); + } + + // Create FormData for the backend request + const backendFormData = new FormData(); + + // Convert the file to a Buffer for the Node.js environment + const buffer = Buffer.from(await file.arrayBuffer()); + + // Append the file buffer to FormData + backendFormData.append('file', buffer, file.name); + + // Send the file to the backend service + const backendResponse = await fetch(`http://127.0.0.1:8000/collections/${encodeURIComponent(collectionName)}/documents/file`, { + method: 'POST', + body: backendFormData, + headers: backendFormData.getHeaders() + }); + + const backendResponseText = await backendResponse.text(); + + if (!backendResponse.ok) { + throw new Error(`Failed to upload file to backend: ${backendResponseText}`); + } + + return NextResponse.json({ message: 'File uploaded successfully', data: backendResponseText }, { status: 200 }); + } catch (error: any) { + console.error('Error during file upload:', error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/playground/ragchat/collections/[collectionName]/documents/url/route.tsx b/src/app/api/playground/ragchat/collections/[collectionName]/documents/url/route.tsx new file mode 100644 index 00000000..8a6e1fd4 --- /dev/null +++ b/src/app/api/playground/ragchat/collections/[collectionName]/documents/url/route.tsx @@ -0,0 +1,31 @@ +// src/app/api/playground/ragchat/collections/[collectionName]/documents/url/route.ts +`use server`; + +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest, { params }: { params: { collectionName: string } }) { + const { collectionName } = params; + + try { + const { http_source } = await req.json(); + + const response = await fetch(`http://localhost:8000/collections/${encodeURIComponent(collectionName)}/documents/url`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ http_source }) + }); + + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(`Failed to upload URL: ${responseText}`); + } + + return NextResponse.json({ message: 'URL uploaded successfully', data: responseText }, { status: 200 }); + } catch (error: any) { + console.error('Error uploading URL:', error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/playground/ragchat/collections/[collectionName]/query/route.tsx b/src/app/api/playground/ragchat/collections/[collectionName]/query/route.tsx new file mode 100644 index 00000000..e8149b3a --- /dev/null +++ b/src/app/api/playground/ragchat/collections/[collectionName]/query/route.tsx @@ -0,0 +1,44 @@ +// src/app/api/playground/ragchat/collections/[collectionName]/query/route.ts +'use server'; + +import { NextRequest, NextResponse } from 'next/server'; +import fetch from 'node-fetch'; + +export async function POST(req: NextRequest, { params }: { params: { collectionName: string } }) { + const { collectionName } = params; + + try { + const { question } = await req.json(); + + console.log(`Received question: ${question} for collection: ${collectionName}`); + + // Make the API request to the backend using node-fetch + const response = await fetch(`http://127.0.0.1:8000/collections/${encodeURIComponent(collectionName)}/query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ question }) + }); + + // Check if the response was successful + if (!response.ok) { + const errorText = await response.text(); + console.error(`Failed to query collection: ${errorText}`); + throw new Error(`Failed to query collection: ${errorText}`); + } + + // Parse the backend response + const responseData = await response.json(); + console.log('Backend response data:', responseData); + + // Extract the 'answer' and 'sources' fields + const { answer, sources } = responseData; + + // Return the answer and sources to the client + return NextResponse.json({ answer, sources }, { status: 200 }); + } catch (error: any) { + console.error('Error querying collection:', error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/playground/ragchat/collections/route.ts b/src/app/api/playground/ragchat/collections/route.ts new file mode 100644 index 00000000..5fc07245 --- /dev/null +++ b/src/app/api/playground/ragchat/collections/route.ts @@ -0,0 +1,31 @@ +// src/app/api/playground/ragchat/collections/route.ts +'use server'; + +import { NextRequest, NextResponse } from 'next/server'; +import fetch from 'node-fetch'; + +export async function GET(req: NextRequest) { + console.log('Received request to fetch collections'); + + try { + console.log('Making fetch call to backend service...'); + + const response = await fetch('http://127.0.0.1:8000/collections', { + method: 'GET', + headers: { + Accept: 'application/json' // Ensure Accept header is set properly + } + }); + + const rawText = await response.text(); + console.log('Raw response text from backend:', rawText); + + const data = JSON.parse(rawText); + console.log('Parsed collections data:', data); + + return NextResponse.json(data, { status: 200 }); + } catch (error: any) { + console.error('Error fetching collections:', error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/playground/ragchat/index-files/route.ts b/src/app/api/playground/ragchat/index-files/route.ts new file mode 100644 index 00000000..1918fe3d --- /dev/null +++ b/src/app/api/playground/ragchat/index-files/route.ts @@ -0,0 +1,99 @@ +// src/app/api/playground/ragchat/index-files/route.ts +'use server'; + +import { NextRequest, NextResponse } from 'next/server'; +import fetch from 'node-fetch'; + +async function authenticate(USERNAME: string, API_KEY: string, DS_HOST: string, retries: number = 3): Promise { + for (let attempt = 0; attempt < retries; attempt++) { + try { + console.log('Starting authentication...'); + const authResponse = await fetch(`${DS_HOST}/api/cps/user/v1/user/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from(`${USERNAME}:${API_KEY}`).toString('base64')}` + }, + body: JSON.stringify({}) + }); + + const authText = await authResponse.text(); + console.log('Auth response text:', authText); + + if (!authResponse.ok) { + throw new Error(authText); + } + + const authData = JSON.parse(authText); + console.log('Authentication successful. Token obtained.'); + return authData.access_token; + } catch (error) { + console.error(`Authentication attempt ${attempt + 1} failed:`, error.message); + if (attempt < retries - 1) { + console.log('Retrying in 3 seconds...'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + } else { + throw new Error('Failed to authenticate after multiple attempts'); + } + } + } +} + +async function fetchDocuments(DS_HOST: string, PROJ_KEY: string, dsIndexKey: string, token: string, retries: number = 3) { + for (let attempt = 0; attempt < retries; attempt++) { + try { + console.log('Fetching documents...'); + const response = await fetch(`${DS_HOST}/api/cps/public/v2/project/${PROJ_KEY}/data_indices/${dsIndexKey}/documents/`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }); + + console.log('API response status:', response.status); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText); + } + + const data = await response.json(); + console.log('Fetched documents:', data.documents); + return data.documents.filter((doc: any) => doc.status === 'SUCCESS'); + } catch (error) { + console.error(`Fetch attempt ${attempt + 1} failed:`, error.message); + if (attempt < retries - 1) { + console.log('Retrying in 3 seconds...'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + } else { + throw new Error('Failed to fetch documents after multiple attempts'); + } + } + } +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const dsIndexKey = searchParams.get('indexKey'); + const USERNAME = process.env.DS_USERNAME; + const API_KEY = process.env.DS_API_KEY; + const DS_HOST = process.env.DS_HOST; + const PROJ_KEY = process.env.DS_PROJ_KEY; + + console.log('Received request for data index:', dsIndexKey); + + if (!dsIndexKey || !USERNAME || !API_KEY || !DS_HOST || !PROJ_KEY) { + console.error('Missing required parameters or environment variables', { dsIndexKey, USERNAME, API_KEY, DS_HOST, PROJ_KEY }); + return NextResponse.json({ error: 'Missing required parameters or environment variables' }, { status: 400 }); + } + + try { + const token = await authenticate(USERNAME, API_KEY, DS_HOST); + const documents = await fetchDocuments(DS_HOST, PROJ_KEY, dsIndexKey, token); + return NextResponse.json({ documents }, { status: 200 }); + } catch (error) { + console.error('Server error:', error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/playground/ragchat/query/route.ts b/src/app/api/playground/ragchat/query/route.ts new file mode 100644 index 00000000..16337c5e --- /dev/null +++ b/src/app/api/playground/ragchat/query/route.ts @@ -0,0 +1,107 @@ +// src/app/api/playground/ragchat/query/route.ts +'use server'; + +import { NextRequest, NextResponse } from 'next/server'; +import fetch from 'node-fetch'; +import { dsauthenticate } from '../../../../../utils/dsauthenticate'; + +async function queryRAG( + DS_HOST: string, + DS_TOKEN: string, + DS_PROJ_KEY: string, + indexKey: string, + question: string, + model_id: string, + doc_hash: string | null +) { + const queryUrl = `${DS_HOST}/api/orchestrator/api/v1/query/run`; + console.log('Querying RAG backend:', queryUrl); + + const payload = { + query: { + variables: {}, + template: { + version: '1', + tasks: [ + { + id: 'QA', + kind: 'SemanticRag', + inputs: {}, + parameters: { + question, + model_id, + retr_k: 10, + use_reranker: false, + hybrid_search_text_weight: 0.1, + gen_timeout: 25, + return_prompt: true, + ...(doc_hash ? { doc_id: doc_hash } : {}) // doc_hash is added only if the user selects a specific doc to query + }, + '@resource': { + type: 'semantic_backend_genai_runner', + proj_key: DS_PROJ_KEY, + index_key: indexKey + } + } + ], + outputs: { + answers: { + task_id: 'QA', + output_id: 'answers' + }, + retrieval: { + task_id: 'QA', + output_id: 'retrieval' + } + } + } + } + }; + + try { + const response = await fetch(queryUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Authorization': `Bearer ${DS_TOKEN}` + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText); + } + + const data = await response.json(); + console.log('RAG backend response:', data); + return data.result.outputs.answers[0].prompt; + } catch (error) { + console.error('Error querying RAG backend:', error.message); + throw error; + } +} + +export async function POST(req: NextRequest) { + const { question, dataIndex, docHash } = await req.json(); + const USERNAME = process.env.DS_USERNAME; + const API_KEY = process.env.DS_API_KEY; + const DS_HOST = process.env.DS_HOST; + const DS_PROJ_KEY = process.env.DS_PROJ_KEY; + const DS_MODEL_ID = process.env.DS_MODEL_ID; + + if (!USERNAME || !API_KEY || !DS_HOST || !DS_PROJ_KEY || !DS_MODEL_ID) { + console.error('Missing required parameters or environment variables', { USERNAME, API_KEY, DS_HOST, DS_PROJ_KEY, DS_MODEL_ID }); + return NextResponse.json({ error: 'Missing required parameters or environment variables' }, { status: 400 }); + } + + try { + const token = await dsauthenticate(USERNAME, API_KEY, DS_HOST); + const prompt = await queryRAG(DS_HOST, token, DS_PROJ_KEY, dataIndex, question, DS_MODEL_ID, docHash); + console.log('Prompt received:', prompt); + return NextResponse.json({ prompt }, { status: 200 }); + } catch (error) { + console.error('Server error:', error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/playground/ragchat/upload/route.ts b/src/app/api/playground/ragchat/upload/route.ts new file mode 100644 index 00000000..2059f5ca --- /dev/null +++ b/src/app/api/playground/ragchat/upload/route.ts @@ -0,0 +1,233 @@ +// pages/api/playground/ragchat/upload.ts +'use server'; + +import { NextResponse, NextRequest } from 'next/server'; +import fetch from 'node-fetch'; + +export async function POST(req: NextRequest) { + try { + const { fileUrl } = await req.json(); + + if (!fileUrl) { + console.error('No file URL found in the request'); + return NextResponse.json({ error: 'No file URL found in the request' }, { status: 400 }); + } + + console.log('File URL received:', fileUrl); + + const USERNAME = process.env.DS_USERNAME; + const API_KEY = process.env.DS_API_KEY; + const DS_HOST = process.env.DS_HOST; + const PROJ_KEY = process.env.DS_PROJ_KEY; + const INDEX_KEY = process.env.DS_INDEX_KEY; + + if (!USERNAME || !API_KEY || !DS_HOST || !PROJ_KEY || !INDEX_KEY) { + console.error('Missing environment variables'); + return NextResponse.json({ error: 'Missing environment variables' }, { status: 500 }); + } + + // Step 1: Authenticate + console.log('Starting authentication...'); + const authResponse = await fetch(`${DS_HOST}/api/cps/user/v1/user/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from(`${USERNAME}:${API_KEY}`).toString('base64')}` + }, + body: JSON.stringify({}) + }); + + const authText = await authResponse.text(); + console.log('Auth response text:', authText); + + if (!authResponse.ok) { + console.error('Error during authentication:', authText); + return NextResponse.json({ error: authText }, { status: authResponse.status }); + } + + const authData = JSON.parse(authText); + const token = authData.access_token; + console.log('Authentication successful. Token obtained.'); + + // Step 2: Upload & Convert PDF + console.log('Starting document upload and conversion...'); + const uploadResponse = await fetch(`${DS_HOST}/api/cps/public/v1/project/${PROJ_KEY}/data_indices/${INDEX_KEY}/actions/ccs_convert_upload`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + file_url: [fileUrl] + }) + }); + + const uploadText = await uploadResponse.text(); + console.log('Upload response text:', uploadText); + + if (!uploadResponse.ok) { + console.error('Error during document upload and conversion:', uploadText); + return NextResponse.json({ error: uploadText }, { status: uploadResponse.status }); + } + + const uploadData = JSON.parse(uploadText); + const taskId = uploadData.task_id; + console.log('Document upload and conversion initiated successfully. Task ID:', taskId); + + // Step 3: Polling Task Status for PDF Conversion + console.log('Polling task status for PDF conversion...'); + let taskStatus = 'PENDING'; + let statusData; + let retries = 0; + const maxRetries = 20; + while (taskStatus === 'PENDING' || taskStatus === 'RETRY' || taskStatus === 'STARTED') { + const statusResponse = await fetch(`${DS_HOST}/api/cps/public/v2/project/${PROJ_KEY}/celery_tasks/${taskId}?wait=10`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}` + } + }); + + const statusText = await statusResponse.text(); + console.log('Status response text:', statusText); + + if (!statusResponse.ok) { + console.error('Error during status polling:', statusText); + return NextResponse.json({ error: statusText }, { status: statusResponse.status }); + } + + statusData = JSON.parse(statusText); + taskStatus = statusData.task_status; + console.log(`Task status: ${taskStatus}`); + + if (taskStatus === 'SUCCESS') { + break; + } else if (taskStatus === 'FAILURE') { + console.error('Task failed:', statusData); + return NextResponse.json({ error: 'Task failed' }, { status: 500 }); + } + + retries++; + if (retries >= maxRetries) { + console.error('Max retries reached:', statusData); + return NextResponse.json({ error: 'Max retries reached' }, { status: 500 }); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait for 10 seconds before the next retry + } + + if (!statusData.result) { + console.error('Task result is null'); + return NextResponse.json({ error: 'Task result is null' }, { status: 500 }); + } + + // Step 4: Fetch Document Details + const transactionId = statusData.result.transaction_id; + console.log('Fetching document details...'); + const docDetailsResponse = await fetch( + `${DS_HOST}/api/cps/public/v2/project/${PROJ_KEY}/data_indices/${INDEX_KEY}/documents/transactions/${transactionId}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}` + } + } + ); + + const docDetailsText = await docDetailsResponse.text(); + console.log('Document details response text:', docDetailsText); + + if (!docDetailsResponse.ok) { + console.error('Error fetching document details:', docDetailsText); + return NextResponse.json({ error: docDetailsText }, { status: docDetailsResponse.status }); + } + + const docDetailsData = JSON.parse(docDetailsText); + const docHash = docDetailsData.documents[0]?.document_hash || null; + if (!docHash) { + console.error('Invalid document hash:', docHash); + return NextResponse.json({ error: 'Invalid document hash' }, { status: 500 }); + } + + console.log('Document details fetched successfully. Document hash:', docHash); + + // Step 5: Ingest Document + console.log('Starting document ingestion...'); + const ingestResponse = await fetch(`${DS_HOST}/api/cps/public/v2/project/${PROJ_KEY}/semantic/ingest`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + parameters: { + skip_ingested_docs: true + }, + source: { + type: 'private_data_document', + index_key: INDEX_KEY, + document_hash: docHash, + proj_key: PROJ_KEY + } + }) + }); + + const ingestText = await ingestResponse.text(); + console.log('Ingest response text:', ingestText); + + if (!ingestResponse.ok) { + console.error('Error during document ingestion:', ingestText); + return NextResponse.json({ error: ingestText }, { status: ingestResponse.status }); + } + + const ingestData = JSON.parse(ingestText); + const ingestTaskId = ingestData.task_id; + console.log('Document ingestion initiated successfully. Task ID:', ingestTaskId); + + // Step 6: Polling Task Status for Ingestion + console.log('Polling task status for ingestion...'); + taskStatus = 'PENDING'; + retries = 0; + while (taskStatus === 'PENDING' || taskStatus === 'RETRY' || taskStatus === 'STARTED') { + const ingestStatusResponse = await fetch(`${DS_HOST}/api/cps/public/v2/project/${PROJ_KEY}/celery_tasks/${ingestTaskId}?wait=10`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}` + } + }); + + const ingestStatusText = await ingestStatusResponse.text(); + console.log('Ingest status response text:', ingestStatusText); + + if (!ingestStatusResponse.ok) { + console.error('Error during ingest status polling:', ingestStatusText); + return NextResponse.json({ error: ingestStatusText }, { status: ingestStatusResponse.status }); + } + + const ingestStatusData = JSON.parse(ingestStatusText); + taskStatus = ingestStatusData.task_status; + console.log(`Ingest task status: ${taskStatus}`); + + if (taskStatus === 'SUCCESS') { + break; + } else if (taskStatus === 'FAILURE') { + console.error('Ingest task failed:', ingestStatusData); + return NextResponse.json({ error: 'Ingest task failed' }, { status: 500 }); + } + + retries++; + if (retries >= maxRetries) { + console.error('Max retries reached for ingestion:', ingestStatusData); + return NextResponse.json({ error: 'Max retries reached for ingestion' }, { status: 500 }); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait for 10 seconds before the next retry + } + + console.log('Document ingestion completed successfully.'); + return NextResponse.json({ uploadData, ingestData }); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/playground/ragchat/page.tsx b/src/app/playground/ragchat/page.tsx new file mode 100644 index 00000000..9bd88d54 --- /dev/null +++ b/src/app/playground/ragchat/page.tsx @@ -0,0 +1,619 @@ +// src/app/playground/ragchat +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; +import { AppLayout } from '@/components/AppLayout'; +import { Button } from '@patternfly/react-core/dist/dynamic/components/Button'; +import { Form, FormGroup } from '@patternfly/react-core/dist/dynamic/components/Form'; +import { TextInput } from '@patternfly/react-core/dist/dynamic/components/TextInput'; +import { Select } from '@patternfly/react-core/dist/dynamic/components/Select'; +import { SelectOption, SelectList } from '@patternfly/react-core/dist/dynamic/components/Select'; +import { MenuToggle, MenuToggleElement } from '@patternfly/react-core/dist/dynamic/components/MenuToggle'; +import { Spinner } from '@patternfly/react-core/dist/dynamic/components/Spinner'; +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/dynamic/components/ToggleGroup'; +import { + MultipleFileUpload, + MultipleFileUploadMain, + MultipleFileUploadStatus, + MultipleFileUploadStatusItem +} from '@patternfly/react-core/dist/dynamic/components/MultipleFileUpload'; +import { Modal } from '@patternfly/react-core/dist/dynamic/components/Modal'; +import { HelperText, HelperTextItem } from '@patternfly/react-core/dist/dynamic/components/HelperText'; +import UploadIcon from '@patternfly/react-icons/dist/esm/icons/upload-icon'; +import UserIcon from '@patternfly/react-icons/dist/dynamic/icons/user-icon'; +import CopyIcon from '@patternfly/react-icons/dist/dynamic/icons/copy-icon'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBroom } from '@fortawesome/free-solid-svg-icons'; +import Image from 'next/image'; +import styles from './ragchat.module.css'; + +interface Message { + text: string; + isUser: boolean; +} + +interface ReadFile { + fileName: string; + data?: string; + loadResult?: 'danger' | 'success'; + loadError?: DOMException; +} + +const Page: React.FC = () => { + // State variables + const [question, setQuestion] = useState(''); + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isCollectionSelectOpen, setIsCollectionSelectOpen] = useState(false); + const [collections, setCollections] = useState([]); + const [selectedCollection, setSelectedCollection] = useState(null); + const [uploadCollectionName, setUploadCollectionName] = useState(''); // Collection name input for file upload + const [uploadURL, setUploadURL] = useState(''); + const [ingestMethod, setIngestMethod] = useState('url'); + const messagesContainerRef = useRef(null); + + // File upload state + const [currentFiles, setCurrentFiles] = useState([]); + const [readFileData, setReadFileData] = useState([]); + const [showStatus, setShowStatus] = useState(false); + const [statusIcon, setStatusIcon] = useState<'inProgress' | 'success' | 'danger'>('inProgress'); + const [modalText, setModalText] = useState(''); + const [urlCollectionName, setUrlCollectionName] = useState(''); // Collection name for URL upload + const [fileCollectionName, setFileCollectionName] = useState(''); // Collection name for file upload + + useEffect(() => { + let isMounted = true; + let timeoutId: NodeJS.Timeout; + + fetchCollections(); + + return () => { + isMounted = false; + clearTimeout(timeoutId); + }; + }, []); + + // Handlers for collection selection and toggle + const onCollectionToggleClick = () => { + setIsCollectionSelectOpen((prev) => !prev); + + // Force refresh collections when dropdown is opened + if (!isCollectionSelectOpen) { + console.log('Dropdown opened, fetching collections'); + fetchCollections(); + } + }; + + useEffect(() => { + if (currentFiles.length > 0) { + setShowStatus(true); + } else { + setShowStatus(false); + } + }, [currentFiles]); + + useEffect(() => { + if (readFileData.length < currentFiles.length) { + setStatusIcon('inProgress'); + } else if (readFileData.every((file) => file.loadResult === 'success')) { + setStatusIcon('success'); + } else { + setStatusIcon('danger'); + } + }, [readFileData, currentFiles]); + + // Handlers for collection selection + // const onCollectionToggleClick = () => { + // setIsCollectionSelectOpen(!isCollectionSelectOpen); + // }; + + // Fetch collections from the server + const fetchCollections = async () => { + console.log('Fetching collections...'); + try { + const response = await fetch('/api/playground/ragchat/collections'); + if (response.ok) { + const data = await response.json(); + setCollections(data.collections); + console.log('Collections fetched:', data.collections); + } else { + console.error('Failed to fetch collections:', await response.text()); + } + } catch (error) { + console.error('Error fetching collections:', error); + } + }; + const onCollectionSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + setSelectedCollection(String(value)); + setIsCollectionSelectOpen(false); + }; + + const collectionToggle = (toggleRef: React.Ref) => ( + + {selectedCollection ? selectedCollection : 'Select a collection'} + + ); + + const collectionItems = collections.map((collection) => ( + + {collection} + + )); + + // Handler for question input change + const handleQuestionChange = (_event: React.FormEvent, value: string) => { + setQuestion(value); + }; + + // Handler for file upload + // Inside Page.tsx + const handleFileUpload = async () => { + if (!uploadCollectionName || currentFiles.length === 0) { + alert('Please enter collection name and select at least one file'); + return; + } + + const formData = new FormData(); + currentFiles.forEach((file) => { + formData.append('files', file); + }); + + try { + const response = await fetch(`/api/playground/ragchat/collections/${encodeURIComponent(uploadCollectionName)}/documents/file`, { + method: 'POST', + body: formData + }); + + const responseText = await response.text(); + console.log('File upload response:', responseText); + + if (response.ok) { + console.log('File uploaded successfully'); + if (!collections.includes(uploadCollectionName)) { + setCollections([...collections, uploadCollectionName]); + } + // Clear the files after successful upload + setCurrentFiles([]); + setReadFileData([]); + setShowStatus(false); + setStatusIcon('inProgress'); + } else { + console.error('Failed to upload file:', responseText); + alert(`Failed to upload file: ${responseText}`); + } + } catch (error) { + console.error('Error during file upload:', error); + alert('An error occurred during file upload.'); + } + }; + + // Handler for URL upload + const handleURLUpload = async () => { + if (!uploadCollectionName || !uploadURL) { + alert('Please enter collection name and URL'); + return; + } + + try { + const response = await fetch(`/api/playground/ragchat/collections/${encodeURIComponent(uploadCollectionName)}/documents/url`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + http_source: { + url: uploadURL, + headers: {} + } + }) + }); + + const responseData = await response.json(); + console.log('URL upload response:', responseData); + + if (response.ok) { + console.log('URL uploaded successfully'); + if (!collections.includes(uploadCollectionName)) { + setCollections([...collections, uploadCollectionName]); + } + // Optionally, reset the upload URL input + setUploadURL(''); + } else { + console.error('Failed to upload URL:', responseData.error); + alert(`Failed to upload URL: ${responseData.error}`); + } + } catch (error) { + console.error('Error during URL upload:', error); + alert('An error occurred during URL upload.'); + } + }; + + // Handler for deleting a collection + const handleDeleteCollection = async () => { + if (!selectedCollection) return; + + try { + const response = await fetch(`/api/playground/ragchat/collections/${encodeURIComponent(selectedCollection)}/delete`, { + method: 'DELETE' + }); + + if (response.ok) { + setCollections(collections.filter((collection) => collection !== selectedCollection)); + setSelectedCollection(null); + console.log('Collection deleted successfully'); + } else { + console.error('Failed to delete collection:', await response.text()); + alert('Failed to delete collection.'); + } + } catch (error) { + console.error('Error during collection deletion:', error); + alert('An error occurred during collection deletion.'); + } + }; + + // Handler for submitting a query + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!question.trim() || !selectedCollection) return; + + setMessages((messages) => [...messages, { text: question, isUser: true }]); + setQuestion(''); + + setIsLoading(true); + try { + const response = await fetch(`/api/playground/ragchat/collections/${encodeURIComponent(selectedCollection)}/query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ question }) + }); + + if (!response.ok) { + const errorText = await response.text(); + setMessages((messages) => [...messages, { text: 'Failed to fetch response from the server.', isUser: false }]); + setIsLoading(false); + return; + } + + const data = await response.json(); + const answer = data.answer || ''; + const sources = data.sources || []; + + console.log('Answer:', answer); + console.log('Sources:', sources); + + setMessages((messages) => [...messages, { text: answer, isUser: false, sources }]); + setIsLoading(false); + } catch (error) { + console.error('Error during question submission:', error); + setIsLoading(false); + alert('An error occurred during question submission.'); + } + }; + + // Scroll to the bottom of the messages when new messages are added + useEffect(() => { + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; + } + }, [messages]); + + // Handler for copying text to clipboard + const handleCopyToClipboard = (text: string) => { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(text) + .then(() => { + console.log('Text copied to clipboard'); + }) + .catch((err) => { + console.error('Could not copy text: ', err); + }); + } else { + const textArea = document.createElement('textarea'); + textArea.value = text; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + console.log('Text copied to clipboard'); + } catch (err) { + console.error('Could not copy text: ', err); + } + document.body.removeChild(textArea); + } + }; + + // Handler for cleaning up messages + const handleCleanup = () => { + setMessages([]); + }; + + // Handler for changing ingest method + const handleIngestMethodChange = (event: React.MouseEvent) => { + const id = event.currentTarget.id; + setIngestMethod(id === 'toggle-group-single-file' ? 'file' : 'url'); + }; + + // Functions for file upload + const removeFiles = (namesOfFilesToRemove: string[]) => { + const newCurrentFiles = currentFiles.filter((file) => !namesOfFilesToRemove.includes(file.name)); + const newReadFiles = readFileData.filter((file) => !namesOfFilesToRemove.includes(file.fileName)); + setCurrentFiles(newCurrentFiles); + setReadFileData(newReadFiles); + }; + + const handleFileDrop = (_event: any, droppedFiles: File[]) => { + const currentFileNames = currentFiles.map((file) => file.name); + const reUploads = droppedFiles.filter((file) => currentFileNames.includes(file.name)); + + const newFiles = [ + ...currentFiles.filter((file) => !reUploads.includes(file)), + ...droppedFiles.filter((file) => !currentFileNames.includes(file.name)) + ]; + setCurrentFiles(newFiles); + }; + + const handleReadSuccess = (data: string, file: File) => { + setReadFileData((prevReadFiles) => { + const existingFile = prevReadFiles.find((readFile) => readFile.fileName === file.name); + if (existingFile) { + return prevReadFiles; + } + return [...prevReadFiles, { data, fileName: file.name, loadResult: 'success' }]; + }); + }; + + const handleReadFail = (error: DOMException, file: File) => { + setReadFileData((prevReadFiles) => { + const existingFile = prevReadFiles.find((readFile) => readFile.fileName === file.name); + if (existingFile) { + return prevReadFiles; + } + return [...prevReadFiles, { loadError: error, fileName: file.name, loadResult: 'danger' }]; + }); + }; + + const handleDropRejected = (fileRejections: any[]) => { + console.warn('Files rejected:', fileRejections); + if (fileRejections.length === 1) { + setModalText(`${fileRejections[0].file.name} is not an accepted file type.`); + } else { + const rejectedMessages = fileRejections.reduce((acc, fileRejection) => (acc += `${fileRejection.file.name}, `), ''); + setModalText(`${rejectedMessages} are not accepted file types.`); + } + }; + + const createHelperText = (file: File) => { + const fileResult = readFileData.find((readFile) => readFile.fileName === file.name); + if (fileResult?.loadError) { + return ( + + {fileResult.loadError.toString()} + + ); + } + }; + + const successfullyReadFileCount = readFileData.filter((fileData) => fileData.loadResult === 'success').length; + + // Format metadata for display + const formatMetadata = (metadata: any) => { + return Object.keys(metadata) + .map((key) => `${key}: ${metadata[key]}`) + .join(', '); + }; + + return ( + +
+ {/* Ingest Data Method Toggle */} +
+ Ingest Data Method + + + + +
+ + {/* Collection Name Input for File/URL Upload */} + + setUploadCollectionName(value)} + placeholder="Enter collection name for upload" + /> + + + {/* Ingest Data Section */} + {ingestMethod === 'file' ? ( + <> + {/* File Upload Dropzone */} +
+ + } + titleText="Drag and drop files here" + titleTextSeparator="or" + infoText="Accepted file types: PDF, TXT, DOC, DOCX" + /> + {showStatus && ( + + {currentFiles.map((file) => ( + removeFiles([file.name])} + onReadSuccess={handleReadSuccess} + onReadFail={handleReadFail} + progressHelperText={createHelperText(file)} + /> + ))} + + )} + setModalText('')} + actions={[ + + ]} + > +

{modalText}

+
+
+
+ {/* Upload Files Button */} + + + ) : ( + /* URL Upload Section */ +
+ + setUploadURL(value)} + placeholder="Enter URL to Ingest" + /> + + {/* Made the Upload URL button full width */} + +
+ )} +
+ {/* Messages Container */} +
+ {messages.map((msg, index) => ( +
+ {msg.isUser ? ( + + ) : ( + Bot + )} +
+
+                  {msg.text}
+                
+ {!msg.isUser && msg.sources && ( +
+
+
Sources:
+ {msg.sources.map((source, i) => ( +
+

+ Source {i + 1}: {source.text} +

+

+ Metadata: {formatMetadata(source.metadata)} +

+
+ ))} +
+ )} +
+ {!msg.isUser && ( + + )} +
+ ))} + {isLoading && } +
+ + {/* Cleanup Button */} +
+ +
+ + {/* Chat Form */} +
+
+ {/* Collection Selector for Query Submission */} +
+ Select Collection + + {selectedCollection && ( + + )} +
+ {/* Question Input */} + + + + +
+
+
+
+ ); +}; + +export default Page; diff --git a/src/app/playground/ragchat/ragchat.module.css b/src/app/playground/ragchat/ragchat.module.css new file mode 100644 index 00000000..ef114ad8 --- /dev/null +++ b/src/app/playground/ragchat/ragchat.module.css @@ -0,0 +1,176 @@ +/* Chat Container */ +.chatContainer { + display: flex; + flex-direction: column; + justify-content: flex-start; + width: 100%; + height: 90vh; + background-color: #f7f7f8; + padding: 20px; + box-sizing: border-box; +} + +.modelAndUploadContainer { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.selectDocumentContainer { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + margin-bottom: 10px; +} + +.fileUpload { + display: flex; + align-items: center; + margin-right: 40px; +} + +.fileUpload span { + margin-right: 10px; +} + +/* Model Selector */ +.modelSelector { + display: flex; + align-items: center; +} + +.modelSelectorLabel { + margin-right: 10px; + font-weight: bold; +} + +/* File Upload */ +.fileUpload input[type='file'] { + display: flex; + align-items: center; + margin-right: 40px; +} + +/* Chat Title */ +.chatTitle { + font-size: 1.5rem; + color: #333; + margin-bottom: 20px; + text-align: center; +} + +/* Messages Container */ +.messagesContainer { + flex-grow: 1; + overflow-y: auto; + padding: 20px; + border: 1px solid #e0e0e0; + background-color: white; +} + +/* Message Styling */ +.message { + display: flex; + align-items: flex-start; + margin-bottom: 20px; + font-size: 0.9rem; /* Smaller bot font size */ +} + +.message pre { + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + margin: 0; + flex-grow: 1; + font-size: inherit; /* Inherit the smaller font size */ +} + +/* User Message Styling */ +.chatQuestion { + justify-content: flex-start; + text-align: left; +} + +.userIcon { + margin-left: 10px; + margin-right: 20px; +} + +/* Bot Message Styling */ +.chatAnswer { + justify-content: flex-start; +} + +.botIcon { + margin-right: 10px; + margin-left: 0; +} + +/* Form Container within the Chat Window */ +.chatFormContainer { + display: flex; + align-items: center; + border-top: 1px solid #e0e0e0; + background-color: white; + padding: 10px; +} + +/* Form Styling */ +.chatForm { + display: flex; + width: 100%; + align-items: center; +} + +/* Input Fields Container */ +.inputFieldsContainer { + display: flex; + align-items: center; + flex-grow: 1; +} + +/* Input Fields */ +.inputFields { + display: flex; + flex-direction: column; + flex-grow: 1; + margin-right: 10px; +} + +.inputField { + margin-bottom: 10px; +} + +.sendButton { + display: flex; + align-items: center; + justify-content: center; + background-color: #007bff; + color: white; + border: none; + width: 50px; + height: 50px; + border-radius: 10px; + cursor: pointer; +} + +.sendButton:hover { + background-color: #0056b3; +} + +.spinner { + display: block; + margin: 20px auto; +} + +.boldLabel { + font-weight: bold; +} + +.cleanupButtonContainer { + display: flex; + justify-content: flex-end; + padding-right: 10px; +} diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 854f195c..c37bdb54 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -68,7 +68,8 @@ const AppLayout: React.FunctionComponent = ({ children }) => { label: 'Playground', children: [ { path: '/playground/chat', label: 'Chat' }, - { path: '/playground/endpoints', label: 'Custom Model Endpoints' } + { path: '/playground/endpoints', label: 'Custom Model Endpoints' }, + { path: '/playground/ragchat', label: 'Experimental RAG' } ] } ];