diff options
Diffstat (limited to 'src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx')
-rw-r--r-- | src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx | 922 |
1 files changed, 699 insertions, 223 deletions
diff --git a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx index 8043111b9..6e6ef6212 100644 --- a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx +++ b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx @@ -16,12 +16,14 @@ import { v4 as uuidv4 } from 'uuid'; import { ClientUtils, OmitKeys } from '../../../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../../../fields/Doc'; import { DocData, DocViews } from '../../../../../fields/DocSymbols'; +import { Id } from '../../../../../fields/FieldSymbols'; import { RichTextField } from '../../../../../fields/RichTextField'; import { ScriptField } from '../../../../../fields/ScriptField'; -import { CsvCast, DocCast, NumCast, PDFCast, RTFCast, StrCast } from '../../../../../fields/Types'; +import { CsvCast, DocCast, NumCast, PDFCast, RTFCast, StrCast, VideoCast, AudioCast } from '../../../../../fields/Types'; import { DocUtils } from '../../../../documents/DocUtils'; import { CollectionViewType, DocumentType } from '../../../../documents/DocumentTypes'; import { Docs, DocumentOptions } from '../../../../documents/Documents'; +import { DocServer } from '../../../../DocServer'; import { DocumentManager } from '../../../../util/DocumentManager'; import { ImageUtils } from '../../../../util/Import & Export/ImageUtils'; import { LinkManager } from '../../../../util/LinkManager'; @@ -35,18 +37,25 @@ import { PDFBox } from '../../PDFBox'; import { ScriptingBox } from '../../ScriptingBox'; import { VideoBox } from '../../VideoBox'; import { Agent } from '../agentsystem/Agent'; -import { supportedDocTypes } from '../tools/CreateDocumentTool'; +import { supportedDocTypes } from '../types/tool_types'; import { ASSISTANT_ROLE, AssistantMessage, CHUNK_TYPE, Citation, ProcessingInfo, SimplifiedChunk, TEXT_TYPE } from '../types/types'; import { Vectorstore } from '../vectorstore/Vectorstore'; import './ChatBox.scss'; import MessageComponentBox from './MessageComponent'; -import { ProgressBar } from './ProgressBar'; import { OpenWhere } from '../../OpenWhere'; import { Upload } from '../../../../../server/SharedMediaTypes'; +import { AgentDocumentManager } from '../utils/AgentDocumentManager'; dotenv.config(); -export type parsedDocData = { doc_type: string; data: unknown }; +export type parsedDocData = { + doc_type: string; + data: unknown; + _disable_resource_loading?: boolean; + _sandbox_iframe?: boolean; + _iframe_sandbox?: string; + data_useCors?: boolean; +}; export type parsedDoc = DocumentOptions & parsedDocData; /** * ChatBox is the main class responsible for managing the interaction between the user and the assistant, @@ -67,14 +76,18 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable private _linked_csv_files: { filename: string; id: string; text: string }[] = []; @observable private _isUploadingDocs: boolean = false; @observable private _citationPopup: { text: string; visible: boolean } = { text: '', visible: false }; + @observable private _isFontSizeModalOpen: boolean = false; + @observable private _fontSize: 'small' | 'normal' | 'large' | 'xlarge' = 'normal'; + @observable private _toolReloadModal: { visible: boolean; toolName: string } = { visible: false, toolName: '' }; // Private properties for managing OpenAI API, vector store, agent, and UI elements - private openai: OpenAI; + private openai!: OpenAI; // Using definite assignment assertion private vectorstore_id: string; private vectorstore: Vectorstore; private agent: Agent; private messagesRef: React.RefObject<HTMLDivElement>; private _textInputRef: HTMLInputElement | undefined | null; + private docManager: AgentDocumentManager; /** * Static method that returns the layout string for the field. @@ -95,19 +108,28 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { */ constructor(props: FieldViewProps) { super(props); - makeObservable(this); // Enable MobX observables + makeObservable(this); - // Initialize OpenAI, vectorstore, and agent - this.openai = this.initializeOpenAI(); - if (StrCast(this.dataDoc.vectorstore_id) == '') { - this.vectorstore_id = uuidv4(); - this.dataDoc.vectorstore_id = this.vectorstore_id; - } else { - this.vectorstore_id = StrCast(this.dataDoc.vectorstore_id); - } - this.vectorstore = new Vectorstore(this.vectorstore_id, this.retrieveDocIds); - this.agent = new Agent(this.vectorstore, this.retrieveSummaries, this.retrieveFormattedHistory, this.retrieveCSVData, this.addLinkedUrlDoc, this.createImageInDash, this.createDocInDash, this.createCSVInDash); - this.messagesRef = React.createRef<HTMLDivElement>(); + this.messagesRef = React.createRef(); + this.docManager = new AgentDocumentManager(this); + + // Initialize OpenAI client + this.initializeOpenAI(); + + // Create a unique vectorstore ID for this ChatBox + this.vectorstore_id = uuidv4(); + + // Initialize vectorstore with the document manager + this.vectorstore = new Vectorstore(this.vectorstore_id, this.docManager); + + // Create an agent with the vectorstore + this.agent = new Agent(this.vectorstore, this.retrieveFormattedHistory.bind(this), this.retrieveCSVData.bind(this), this.createImageInDash.bind(this), this.createCSVInDash.bind(this), this.docManager); + + // Set up the tool created callback + this.agent.setToolCreatedCallback(this.handleToolCreated); + + // Add event listeners + this.addScrollListener(); // Reaction to update dataDoc when chat history changes reaction( @@ -122,6 +144,9 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this.dataDoc.data = JSON.stringify(serializableHistory); } ); + + // Initialize font size from saved preference + this.initFontSize(); } /** @@ -131,22 +156,53 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { */ @action addDocToVectorstore = async (newLinkedDoc: Doc) => { - this._uploadProgress = 0; - this._currentStep = 'Initializing...'; - this._isUploadingDocs = true; - try { - // Add the document to the vectorstore + const isAudioOrVideo = VideoCast(newLinkedDoc.data)?.url?.pathname || AudioCast(newLinkedDoc.data)?.url?.pathname; + + // Set UI state to show the processing overlay + runInAction(() => { + this._isUploadingDocs = true; + this._uploadProgress = 0; + this._currentStep = isAudioOrVideo ? 'Preparing media file...' : 'Processing document...'; + }); + + // Process the document first to ensure it has a valid ID + await this.docManager.processDocument(newLinkedDoc); + + // Add the document to the vectorstore which will also register chunks await this.vectorstore.addAIDoc(newLinkedDoc, this.updateProgress); - } catch (error) { - console.error('Error uploading document:', error); - this._currentStep = 'Error during upload'; - } finally { + + // Give a slight delay to show the completion message + if (this._uploadProgress === 100) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + // Reset UI state + runInAction(() => { + this._isUploadingDocs = false; + this._uploadProgress = 0; + this._currentStep = ''; + }); + + return true; + } catch (err) { + console.error('Error adding document to vectorstore:', err); + + // Show error in UI + runInAction(() => { + this._currentStep = `Error: ${err instanceof Error ? err.message : 'Failed to process document'}`; + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Reset UI state runInAction(() => { this._isUploadingDocs = false; this._uploadProgress = 0; this._currentStep = ''; }); + + return false; } }; @@ -157,10 +213,18 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { */ @action updateProgress = (progress: number, step: string) => { - this._uploadProgress = progress; + // Ensure progress is within expected bounds + const validProgress = Math.min(Math.max(0, progress), 100); + this._uploadProgress = validProgress; this._currentStep = step; + + // Force UI update + if (process.env.NODE_ENV !== 'production') { + console.log(`Progress: ${validProgress}%, Step: ${step}`); + } }; + //TODO: Update for new chunk_simpl on agentDocument /** * Adds a CSV file for analysis by sending it to OpenAI and generating a summary. * @param newLinkedDoc The linked document representing the CSV file. @@ -229,7 +293,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { apiKey: process.env.OPENAI_KEY, dangerouslyAllowBrowser: true, }; - return new OpenAI(configuration); + this.openai = new OpenAI(configuration); } /** @@ -367,27 +431,6 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }; /** - * Adds a linked document from a URL for future reference and analysis. - * @param url The URL of the document to add. - * @param id The unique identifier for the document. - */ - @action - addLinkedUrlDoc = async (url: string, id: string) => { - const doc = Docs.Create.WebDocument(url, { data_useCors: true }); - - const linkDoc = Docs.Create.LinkDocument(this.Document, doc); - LinkManager.Instance.addLink(linkDoc); - - const chunkToAdd = { - chunkId: id, - chunkType: CHUNK_TYPE.URL, - url: url, - }; - - doc.chunk_simpl = JSON.stringify({ chunks: [chunkToAdd] }); - }; - - /** * Getter to retrieve the current user's name from the client utils. */ @computed @@ -408,7 +451,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { if (doc) { LinkManager.Instance.addLink(Docs.Create.LinkDocument(this.Document, doc)); this._props.addDocument?.(doc); - DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => {}).then(() => this.addCSVForAnalysis(doc, id)); + DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => this.addCSVForAnalysis(doc, id)); } }); @@ -440,20 +483,33 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { private createCollectionWithChildren = (data: parsedDoc[], insideCol: boolean): Opt<Doc>[] => data.map(doc => this.whichDoc(doc, insideCol)); @action - whichDoc = (doc: parsedDoc, insideCol: boolean): Opt<Doc> => { + public whichDoc = (doc: parsedDoc, insideCol: boolean): Opt<Doc> => { const options = OmitKeys(doc, ['doct_type', 'data']).omit as DocumentOptions; const data = (doc as parsedDocData).data; const ndoc = (() => { switch (doc.doc_type) { default: - case supportedDocTypes.text: return Docs.Create.TextDocument(data as string, options); + case supportedDocTypes.note: return Docs.Create.TextDocument(data as string, options); case supportedDocTypes.comparison: return this.createComparison(JSON.parse(data as string) as parsedDoc[], options); case supportedDocTypes.flashcard: return this.createFlashcard(JSON.parse(data as string) as parsedDoc[], options); case supportedDocTypes.deck: return this.createDeck(JSON.parse(data as string) as parsedDoc[], options); case supportedDocTypes.image: return Docs.Create.ImageDocument(data as string, options); case supportedDocTypes.equation: return Docs.Create.EquationDocument(data as string, options); case supportedDocTypes.notetaking: return Docs.Create.NoteTakingDocument([], options); - case supportedDocTypes.web: return Docs.Create.WebDocument(data as string, { ...options, data_useCors: true }); + case supportedDocTypes.web: { + // Create web document with enhanced safety options + const webOptions = { + ...options, + data_useCors: true + }; + + // If iframe_sandbox was passed from AgentDocumentManager, add it to the options + if ('_iframe_sandbox' in options) { + webOptions._iframe_sandbox = options._iframe_sandbox; + } + + return Docs.Create.WebDocument(data as string, webOptions); + } case supportedDocTypes.dataviz: return Docs.Create.DataVizDocument('/users/rz/Downloads/addresses.csv', options); case supportedDocTypes.pdf: return Docs.Create.PdfDocument(data as string, options); case supportedDocTypes.video: return Docs.Create.VideoDocument(data as string, options); @@ -510,28 +566,6 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }; /** - * Creates a document in the dashboard. - * - * @param {string} doc_type - The type of document to create. - * @param {string} data - The data used to generate the document. - * @param {DocumentOptions} options - Configuration options for the document. - * @returns {Promise<void>} A promise that resolves once the document is created and displayed. - */ - @action - createDocInDash = (pdoc: parsedDoc) => { - const linkAndShowDoc = (doc: Opt<Doc>) => { - if (doc) { - LinkManager.Instance.addLink(Docs.Create.LinkDocument(this.Document, doc)); - this._props.addDocument?.(doc); - DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => {}); - } - }; - const doc = this.whichDoc(pdoc, false); - if (doc) linkAndShowDoc(doc); - return doc; - }; - - /** * Creates a deck of flashcards. * * @param {any} data - The data used to generate the flashcards. Can be a string or an object. @@ -604,83 +638,137 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { */ @action handleCitationClick = async (citation: Citation) => { - const currentLinkedDocs: Doc[] = this.linkedDocs; - const chunkId = citation.chunk_id; + try { + // Extract values from MobX proxy object if needed + const chunkId = typeof citation.chunk_id === 'object' ? (citation.chunk_id as unknown as object).toString() : citation.chunk_id; - for (const doc of currentLinkedDocs) { - if (doc.chunk_simpl) { - const docChunkSimpl = JSON.parse(StrCast(doc.chunk_simpl)) as { chunks: SimplifiedChunk[] }; - const foundChunk = docChunkSimpl.chunks.find(chunk => chunk.chunkId === chunkId); + // For debugging + console.log('Citation clicked:', { + chunkId, + citation: JSON.stringify(citation, null, 2), + }); - if (foundChunk) { - // Handle media chunks specifically + // Get the simplified chunk using the document manager + const { foundChunk, doc, dataDoc } = this.docManager.getSimplifiedChunkById(chunkId); + console.log('doc: ', doc); + console.log('dataDoc: ', dataDoc); + if (!foundChunk || !doc) { + if (doc) { + console.warn(`Chunk not found in document, ${doc.id}, for chunk ID: ${chunkId}`); + DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => {}); + } else { + console.warn(`Chunk not found for chunk ID: ${chunkId}`); + } + return; + } - if (doc.ai_type == 'video' || doc.ai_type == 'audio') { - const directMatchSegmentStart = this.getDirectMatchingSegmentStart(doc, citation.direct_text || '', foundChunk.indexes || []); + console.log(`Found chunk in document:`, foundChunk); - if (directMatchSegmentStart) { - // Navigate to the segment's start time in the media player - await this.goToMediaTimestamp(doc, directMatchSegmentStart, doc.ai_type); - } else { - console.error('No direct matching segment found for the citation.'); - } - } else { - // Handle other chunk types as before - this.handleOtherChunkTypes(foundChunk, citation, doc); - } + // Handle different chunk types + if (foundChunk.chunkType === CHUNK_TYPE.AUDIO || foundChunk.chunkType === CHUNK_TYPE.VIDEO) { + const directMatchSegmentStart = this.getDirectMatchingSegmentStart(doc, citation.direct_text || '', foundChunk.indexes || []); + if (directMatchSegmentStart) { + await this.goToMediaTimestamp(doc, directMatchSegmentStart, foundChunk.chunkType); + } else { + console.error('No direct matching segment found for the citation.'); + } + } else if (foundChunk.chunkType === CHUNK_TYPE.TABLE || foundChunk.chunkType === CHUNK_TYPE.IMAGE) { + console.log('here: ', foundChunk); + this.handleOtherChunkTypes(foundChunk as SimplifiedChunk, citation, doc); + } else { + if (doc.type === 'web') { + DocumentManager.Instance.showDocument(doc, { openLocation: OpenWhere.addRight }, () => {}); + return; } + this.handleOtherChunkTypes(foundChunk, citation, doc, dataDoc); + // Show the chunk text in citation popup + const chunkText = citation.direct_text || 'Text content not available'; + this.showCitationPopup(chunkText); + + // Also navigate to the document + DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => {}); } + } catch (error) { + console.error('Error handling citation click:', error); } }; + /** + * Finds a matching segment in a document based on text content. + * @param doc The document to search in + * @param citationText The text to find in the document + * @param indexesOfSegments Optional indexes of segments to search in + * @returns The starting timestamp of the matching segment, or -1 if not found + */ getDirectMatchingSegmentStart = (doc: Doc, citationText: string, indexesOfSegments: string[]): number => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const originalSegments = JSON.parse(StrCast(doc.original_segments!)).map((segment: any, index: number) => ({ - index: index.toString(), - text: segment.text, - start: segment.start, - end: segment.end, - })); - - if (!Array.isArray(originalSegments) || originalSegments.length === 0 || !Array.isArray(indexesOfSegments)) { - return 0; + if (!doc || !citationText) return -1; + + // Get original segments using document manager + const original_segments = this.docManager.getOriginalSegments(doc); + + if (!original_segments || !Array.isArray(original_segments) || original_segments.length === 0) { + return -1; } - // Create itemsToSearch array based on indexesOfSegments - const itemsToSearch = indexesOfSegments.map((indexStr: string) => { - const index = parseInt(indexStr, 10); - const segment = originalSegments[index]; - return { text: segment.text, start: segment.start }; - }); + let segments = original_segments; + + // If specific indexes are provided, filter segments by those indexes + if (indexesOfSegments && indexesOfSegments.length > 0) { + segments = original_segments.filter((segment: any) => indexesOfSegments.includes(segment.index)); + } - console.log('Constructed itemsToSearch:', itemsToSearch); + // If no segments match the indexes, use all segments + if (segments.length === 0) { + segments = original_segments; + } + + // First try to find an exact match + const exactMatch = segments.find((segment: any) => segment.text && segment.text.includes(citationText)); - // Helper function to calculate word overlap score + if (exactMatch) { + return exactMatch.start; + } + + // If no exact match, find segment with best word overlap const calculateWordOverlap = (text1: string, text2: string): number => { - const words1 = new Set(text1.toLowerCase().split(/\W+/)); - const words2 = new Set(text2.toLowerCase().split(/\W+/)); - const intersection = new Set([...words1].filter(word => words2.has(word))); - return intersection.size / Math.max(words1.size, words2.size); // Jaccard similarity + if (!text1 || !text2) return 0; + + const words1 = text1.toLowerCase().split(/\s+/); + const words2 = text2.toLowerCase().split(/\s+/); + const wordSet1 = new Set(words1); + + let overlap = 0; + for (const word of words2) { + if (wordSet1.has(word)) { + overlap++; + } + } + + // Return percentage of overlap relative to the shorter text + return overlap / Math.min(words1.length, words2.length); }; - // Search for the best matching segment - let bestMatchStart = 0; - let bestScore = 0; - - console.log(`Searching for best match for query: "${citationText}"`); - itemsToSearch.forEach(item => { - const score = calculateWordOverlap(citationText, item.text); - console.log(`Comparing query to segment: "${item.text}" | Score: ${score}`); - if (score > bestScore) { - bestScore = score; - bestMatchStart = item.start; + // Find segment with highest word overlap + let bestMatch = null; + let highestOverlap = 0; + + for (const segment of segments) { + if (!segment.text) continue; + + const overlap = calculateWordOverlap(segment.text, citationText); + if (overlap > highestOverlap) { + highestOverlap = overlap; + bestMatch = segment; } - }); + } - console.log('Best match found with score:', bestScore, '| Start time:', bestMatchStart); + // Only return matches with significant overlap (more than 30%) + if (bestMatch && highestOverlap > 0.3) { + return bestMatch.start; + } - // Return the start time of the best match - return bestMatchStart; + // If no good match found, return the start of the first segment as fallback + return segments.length > 0 ? segments[0].start : -1; }; /** @@ -714,7 +802,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { * @param citation The citation object. * @param doc The document containing the chunk. */ - handleOtherChunkTypes = (foundChunk: SimplifiedChunk, citation: Citation, doc: Doc) => { + handleOtherChunkTypes = (foundChunk: SimplifiedChunk, citation: Citation, doc: Doc, dataDoc?: Doc) => { switch (foundChunk.chunkType) { case CHUNK_TYPE.IMAGE: case CHUNK_TYPE.TABLE: @@ -729,6 +817,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => {}); return; } + const x1 = parseFloat(values[0]) * Doc.NativeWidth(doc); const y1 = parseFloat(values[1]) * Doc.NativeHeight(doc) + foundChunk.startPage * Doc.NativeHeight(doc); const x2 = parseFloat(values[2]) * Doc.NativeWidth(doc); @@ -736,31 +825,183 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const annotationKey = '$' + Doc.LayoutDataKey(doc) + '_annotations'; - const existingDoc = DocListCast(doc[annotationKey]).find(d => d.citation_id === citation.citation_id); + const existingDoc = DocListCast(doc[DocData][annotationKey]).find(d => d.citation_id === citation.citation_id); + if (existingDoc) { + existingDoc.x = x1; + existingDoc.y = y1; + existingDoc._width = x2 - x1; + existingDoc._height = y2 - y1; + } const highlightDoc = existingDoc ?? this.createImageCitationHighlight(x1, y1, x2, y2, citation, annotationKey, doc); - DocumentManager.Instance.showDocument(highlightDoc, { willZoomCentered: true }, () => {}); + //doc.layout_scroll = y1; + doc._layout_curPage = foundChunk.startPage + 1; + DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => {}); + //DocumentManager.Instance.showDocument(highlightDoc, { willZoomCentered: true }, () => {}); } break; case CHUNK_TYPE.TEXT: - this._citationPopup = { text: citation.direct_text ?? 'No text available', visible: true }; - setTimeout(() => (this._citationPopup.visible = false), 3000); + { + this._citationPopup = { text: citation.direct_text ?? 'No text available', visible: true }; + this.startCitationPopupTimer(); - DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => { - const firstView = Array.from(doc[DocViews])[0] as DocumentView; - (firstView.ComponentView as PDFBox)?.gotoPage?.(foundChunk.startPage ?? 0); - (firstView.ComponentView as PDFBox)?.search?.(citation.direct_text ?? ''); - }); + // Check if the document is a PDF (has a PDF viewer component) + const isPDF = PDFCast(dataDoc!.data) !== null || dataDoc!.type === DocumentType.PDF; + + // First ensure document is fully visible before trying to access its views + this.ensureDocumentIsVisible(dataDoc!, isPDF, citation, foundChunk, doc); + } break; case CHUNK_TYPE.CSV: case CHUNK_TYPE.URL: - DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }); + DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => { + console.log(`Showing web document in viewer with URL: ${foundChunk.url}`); + }); break; default: console.error('Unhandled chunk type:', foundChunk.chunkType); break; } }; + + /** + * Ensures a document is fully visible and rendered before performing actions on it + * @param doc The document to ensure is visible + * @param isPDF Whether this is a PDF document + * @param citation The citation information + * @param foundChunk The chunk information + * @param doc The document to ensure is visible + */ + ensureDocumentIsVisible = (doc: Doc, isPDF: boolean, citation: Citation, foundChunk: SimplifiedChunk, layoutDoc?: Doc) => { + try { + // First, check if the document already has views and is rendered + const hasViews = doc[DocViews] && doc[DocViews].size > 0; + + console.log(`Document ${doc.id}: Current state - hasViews: ${hasViews}, isPDF: ${isPDF}`); + + if (hasViews) { + // Document is already rendered, proceed with accessing its view + this.processPDFDocumentView(doc, isPDF, citation, foundChunk); + return; + } else if (layoutDoc) { + this.processPDFDocumentView(layoutDoc, isPDF, citation, foundChunk); + return; + } + + // If document is not rendered yet, show it and wait for it to be ready + console.log(`Document ${doc.id} needs to be shown first`); + + // Force document to be rendered by using willZoomCentered: true + DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => { + // Wait a bit for the document to be fully rendered (longer than our previous attempts) + setTimeout(() => { + // Now manually check if document view exists and is valid + this.verifyAndProcessDocumentView(doc, isPDF, citation, foundChunk, 1); + }, 800); // Increased initial delay + }); + } catch (error) { + console.error('Error ensuring document visibility:', error); + // Show the document anyway as a fallback + DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }); + } + }; + + /** + * Verifies document view exists and processes it, with retries if needed + */ + verifyAndProcessDocumentView = (doc: Doc, isPDF: boolean, citation: Citation, foundChunk: SimplifiedChunk, attempt: number) => { + // Diagnostic info + console.log(`Verify attempt ${attempt}: Document views for ${doc.id}:`, doc[DocViews] ? `Found ${doc[DocViews].size} views` : 'No views'); + + // Double-check document exists in current document system + const docExists = DocServer.GetCachedRefField(doc[Id]) !== undefined; + if (!docExists) { + console.warn(`Document ${doc.id} no longer exists in document system`); + return; + } + + try { + if (!doc[DocViews] || doc[DocViews].size === 0) { + if (attempt >= 5) { + console.error(`Maximum verification attempts (${attempt}) reached for document ${doc.id}`); + + // Last resort: force re-creation of the document view + if (isPDF) { + console.log('Forcing document recreation as last resort'); + DocumentManager.Instance.showDocument(doc, { + willZoomCentered: true, + }); + } + return; + } + + // Let's try explicitly requesting the document be shown again + if (attempt > 2) { + console.log(`Attempt ${attempt}: Re-requesting document be shown`); + DocumentManager.Instance.showDocument(doc, { + willZoomCentered: true, + openLocation: attempt % 2 === 0 ? OpenWhere.addRight : undefined, + }); + } + + // Use exponential backoff for retries + const nextDelay = Math.min(2000, 500 * Math.pow(1.5, attempt)); + console.log(`Scheduling retry ${attempt + 1} in ${nextDelay}ms`); + + setTimeout(() => { + this.verifyAndProcessDocumentView(doc, isPDF, citation, foundChunk, attempt + 1); + }, nextDelay); + return; + } + + this.processPDFDocumentView(doc, isPDF, citation, foundChunk); + } catch (error) { + console.error(`Error on verification attempt ${attempt}:`, error); + if (attempt < 5) { + setTimeout( + () => { + this.verifyAndProcessDocumentView(doc, isPDF, citation, foundChunk, attempt + 1); + }, + 500 * Math.pow(1.5, attempt) + ); + } + } + }; + + /** + * Processes a PDF document view once we're sure it exists + */ + processPDFDocumentView = (doc: Doc, isPDF: boolean, citation: Citation, foundChunk: SimplifiedChunk) => { + try { + const views = Array.from(doc[DocViews] || []); + if (!views.length) { + console.warn('No document views found in document that should have views'); + return; + } + + const firstView = views[0] as DocumentView; + if (!firstView) { + console.warn('First view is invalid'); + return; + } + + console.log(`Successfully found document view for ${doc.id}:`, firstView.ComponentView ? `Component: ${firstView.ComponentView.constructor.name}` : 'No component view'); + + if (!firstView.ComponentView) { + console.warn('Component view not available'); + return; + } + + // For PDF documents, perform fuzzy search + if (isPDF && firstView.ComponentView && citation.direct_text) { + const pdfComponent = firstView.ComponentView as PDFBox; + this.ensureFuzzySearchAndExecute(pdfComponent, citation.direct_text.trim(), foundChunk.startPage); + } + } catch (error) { + console.error('Error processing PDF document view:', error); + } + }; + /** * Creates an annotation highlight on a PDF document for image citations. * @param x1 X-coordinate of the top-left corner of the highlight. @@ -780,7 +1021,8 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { _height: y2 - y1, backgroundColor: 'rgba(255, 255, 0, 0.5)', }); - highlight_doc.$citation_id = citation.citation_id; + highlight_doc[DocData].citation_id = citation.citation_id; + highlight_doc.freeform_scale = 1; Doc.AddDocToList(pdfDoc[DocData], annotationKey, highlight_doc); highlight_doc.annotationOn = pdfDoc; Doc.SetContainer(highlight_doc, pdfDoc); @@ -860,6 +1102,16 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } }); this.addScrollListener(); + + // Initialize the document manager by finding existing documents + this.docManager.initializeFindDocsFreeform(); + + // If there are stored doc IDs in our list of docs to add, process them + if (this._linked_docs_to_add.size > 0) { + this._linked_docs_to_add.forEach(async doc => { + await this.docManager.processDocument(doc); + }); + } } /** @@ -871,58 +1123,6 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } /** - * Getter that retrieves all linked documents for the current document. - */ - @computed - get linkedDocs() { - return LinkManager.Instance.getAllRelatedLinks(this.Document) - .map(d => DocCast(LinkManager.getOppositeAnchor(d, this.Document))) - .map(d => DocCast(d?.annotationOn, d)) - .filter(d => d) - .map(d => d!); - } - - /** - * Getter that retrieves document IDs of linked documents that have AI-related content. - */ - @computed - get docIds() { - return LinkManager.Instance.getAllRelatedLinks(this.Document) - .map(d => DocCast(LinkManager.getOppositeAnchor(d, this.Document))) - .map(d => DocCast(d?.annotationOn, d)) - .filter(d => d) - .map(d => d!) - .filter(d => { - console.log(d.ai_doc_id); - return d.ai_doc_id; - }) - .map(d => StrCast(d.ai_doc_id)); - } - - /** - * Getter that retrieves summaries of all linked documents. - */ - @computed - get summaries(): string { - return ( - LinkManager.Instance.getAllRelatedLinks(this.Document) - .map(d => DocCast(LinkManager.getOppositeAnchor(d, this.Document))) - .map(d => DocCast(d?.annotationOn, d)) - .filter(d => d?.summary) - .map((doc, index) => { - if (PDFCast(doc?.data)) { - return `<summary file_name="${PDFCast(doc!.data)!.url.pathname}" applicable_tools=["rag"]>${doc!.summary}</summary>`; - } else if (CsvCast(doc?.data)) { - return `<summary file_name="${CsvCast(doc!.data)!.url.pathname}" applicable_tools=["dataAnalysis"]>${doc!.summary}</summary>`; - } else { - return `${index + 1}) ${doc?.summary}`; - } - }) - .join('\n') + '\n' - ); - } - - /** * Getter that retrieves all linked CSV files for analysis. */ @computed get linkedCSVs(): { filename: string; id: string; text: string }[] { @@ -947,22 +1147,14 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // Other helper methods for retrieving document data and processing - retrieveSummaries = () => { - return this.summaries; - }; - retrieveCSVData = () => { return this.linkedCSVs; }; - retrieveFormattedHistory = () => { + retrieveFormattedHistory = (): string => { return this.formattedHistory; }; - retrieveDocIds = () => { - return this.docIds; - }; - /** * Handles follow-up questions when the user clicks on them. * Automatically sets the input value to the clicked follow-up question. @@ -977,21 +1169,270 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { setInputRef = (r: HTMLInputElement) => (this._textInputRef = r); setDictationRef = (r: DictationButton) => (this._dictation = r); /** - * Renders the chat interface, including the message list, input field, and other UI elements. + * Handles tool creation notification and shows the reload modal + * @param toolName The name of the tool that was created + */ + @action + handleToolCreated = (toolName: string) => { + this._toolReloadModal = { + visible: true, + toolName: toolName, + }; + }; + + /** + * Closes the tool reload modal + */ + @action + closeToolReloadModal = () => { + this._toolReloadModal = { + visible: false, + toolName: '', + }; + }; + + /** + * Handles the reload confirmation and triggers page reload + */ + @action + handleReloadConfirmation = async () => { + // Close the modal first + this.closeToolReloadModal(); + + try { + // Perform the deferred tool save operation + const saveSuccess = await this.agent.performDeferredToolSave(); + + if (saveSuccess) { + console.log('Tool saved successfully, proceeding with reload...'); + } else { + console.warn('Tool save failed, but proceeding with reload anyway...'); + } + } catch (error) { + console.error('Error during deferred tool save:', error); + } + + // Trigger page reload to rebuild webpack and load the new tool + setTimeout(() => { + window.location.reload(); + }, 100); + }; + + /** + * Toggles the font size modal visibility + */ + @action + toggleFontSizeModal = () => { + this._isFontSizeModalOpen = !this._isFontSizeModalOpen; + }; + + /** + * Changes the font size and applies it to the chat interface + * @param size The new font size to apply + */ + @action + changeFontSize = (size: 'small' | 'normal' | 'large' | 'xlarge') => { + this._fontSize = size; + this._isFontSizeModalOpen = false; + + // Save preference to localStorage if needed + if (typeof window !== 'undefined') { + localStorage.setItem('chatbox-font-size', size); + } + }; + + /** + * Initializes font size from saved preference + */ + initFontSize = () => { + if (typeof window !== 'undefined') { + const savedSize = localStorage.getItem('chatbox-font-size'); + if (savedSize && ['small', 'normal', 'large', 'xlarge'].includes(savedSize)) { + this._fontSize = savedSize as 'small' | 'normal' | 'large' | 'xlarge'; + } + } + }; + + /** + * Renders a font size icon SVG + */ + renderFontSizeIcon = () => ( + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <polyline points="4 7 4 4 20 4 20 7"></polyline> + <line x1="9" y1="20" x2="15" y2="20"></line> + <line x1="12" y1="4" x2="12" y2="20"></line> + </svg> + ); + + /** + * Shows the citation popup with the given text. + * @param text The text to display in the popup. + */ + @action + showCitationPopup = (text: string) => { + this._citationPopup = { + text: text || 'No text available', + visible: true, + }; + this.startCitationPopupTimer(); + }; + + /** + * Closes the citation popup. + */ + @action + closeCitationPopup = () => { + this._citationPopup.visible = false; + }; + + /** + * Starts the auto-close timer for the citation popup. + */ + startCitationPopupTimer = () => { + // Auto-close the popup after 5 seconds + setTimeout(() => this.closeCitationPopup(), 5000); + }; + + /** + * Retry PDF search with exponential backoff + */ + retryPdfSearch = (doc: Doc, citation: Citation, foundChunk: SimplifiedChunk, isPDF: boolean, attempt: number) => { + if (attempt > 5) { + console.error('Maximum retry attempts reached for PDF search'); + return; + } + + const delay = Math.min(2000, 500 * Math.pow(1.5, attempt)); // Exponential backoff with max delay of 2 seconds + + setTimeout(() => { + try { + if (!doc[DocViews] || doc[DocViews].size === 0) { + this.retryPdfSearch(doc, citation, foundChunk, isPDF, attempt + 1); + return; + } + + const views = Array.from(doc[DocViews]); + if (!views.length) { + this.retryPdfSearch(doc, citation, foundChunk, isPDF, attempt + 1); + return; + } + + const firstView = views[0] as DocumentView; + if (!firstView || !firstView.ComponentView) { + this.retryPdfSearch(doc, citation, foundChunk, isPDF, attempt + 1); + return; + } + + const pdfComponent = firstView.ComponentView as PDFBox; + if (isPDF && pdfComponent && citation.direct_text) { + console.log(`PDF component found on attempt ${attempt}, executing search...`); + this.ensureFuzzySearchAndExecute(pdfComponent, citation.direct_text.trim(), foundChunk.startPage); + } + } catch (error) { + console.error(`Error on retry attempt ${attempt}:`, error); + this.retryPdfSearch(doc, citation, foundChunk, isPDF, attempt + 1); + } + }, delay); + }; + + /** + * Ensures fuzzy search is enabled in PDFBox and performs a search + * @param pdfComponent The PDFBox component + * @param searchText The text to search for + * @param startPage Optional page to navigate to before searching + */ + private ensureFuzzySearchAndExecute = (pdfComponent: PDFBox, searchText: string, startPage?: number) => { + if (!pdfComponent) { + console.warn('PDF component is undefined, cannot perform search'); + return; + } + + if (!searchText?.trim()) { + console.warn('Search text is empty, skipping search'); + return; + } + + try { + // Check if the component has required methods + if (typeof pdfComponent.gotoPage !== 'function' || typeof pdfComponent.toggleFuzzySearch !== 'function' || typeof pdfComponent.search !== 'function') { + console.warn('PDF component missing required methods'); + return; + } + + // Navigate to the page if specified + if (typeof startPage === 'number') { + pdfComponent.gotoPage(startPage + 1); + } + + // Always try to enable fuzzy search + try { + // PDFBox.tsx toggles fuzzy search state internally + // We'll call it once to make sure it's enabled + pdfComponent.toggleFuzzySearch(); + } catch (toggleError) { + console.warn('Error toggling fuzzy search:', toggleError); + } + + // Add a sufficient delay to ensure PDF is fully loaded before searching + setTimeout(() => { + try { + console.log('Performing fuzzy search for text:', searchText); + pdfComponent.search(searchText); + } catch (searchError) { + console.error('Error performing search:', searchError); + } + }, 1000); // Increased delay for better reliability + } catch (error) { + console.error('Error in fuzzy search setup:', error); + } + }; + + /** + * Main render method for the ChatBox */ render() { + const fontSizeClass = `font-size-${this._fontSize}`; + return ( - <div className="chat-box"> + <div className={`chat-box ${fontSizeClass}`}> {this._isUploadingDocs && ( <div className="uploading-overlay"> <div className="progress-container"> - <ProgressBar /> - <div className="step-name">{this._currentStep}</div> + <div className="progress-bar-wrapper"> + <div className="progress-bar" style={{ width: `${this._uploadProgress}%` }} /> + </div> + <div className="progress-details"> + <div className="progress-percentage">{Math.round(this._uploadProgress)}%</div> + <div className="step-name">{this._currentStep}</div> + </div> </div> </div> )} <div className="chat-header"> <h2>{this.userName()}'s AI Assistant</h2> + <div className="font-size-control" onClick={this.toggleFontSizeModal}> + {this.renderFontSizeIcon()} + </div> + {this._isFontSizeModalOpen && ( + <div className="font-size-modal"> + <div className={`font-size-option ${this._fontSize === 'small' ? 'active' : ''}`} onClick={() => this.changeFontSize('small')}> + <span className="option-label">Small</span> + <span className="size-preview small">Aa</span> + </div> + <div className={`font-size-option ${this._fontSize === 'normal' ? 'active' : ''}`} onClick={() => this.changeFontSize('normal')}> + <span className="option-label">Normal</span> + <span className="size-preview normal">Aa</span> + </div> + <div className={`font-size-option ${this._fontSize === 'large' ? 'active' : ''}`} onClick={() => this.changeFontSize('large')}> + <span className="option-label">Large</span> + <span className="size-preview large">Aa</span> + </div> + <div className={`font-size-option ${this._fontSize === 'xlarge' ? 'active' : ''}`} onClick={() => this.changeFontSize('xlarge')}> + <span className="option-label">Extra Large</span> + <span className="size-preview xlarge">Aa</span> + </div> + </div> + )} </div> <div className="chat-messages" ref={this.messagesRef}> {this._history.map((message, index) => ( @@ -1003,16 +1444,18 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { </div> <form onSubmit={this.askGPT} className="chat-input"> - <input - ref={this.setInputRef} - type="text" - name="messageInput" - autoComplete="off" - placeholder="Type your message here..." - value={this._inputValue} - onChange={action(e => (this._inputValue = e.target.value))} - disabled={this._isLoading} - /> + <div className="input-container"> + <input + ref={this.setInputRef} + type="text" + name="messageInput" + autoComplete="off" + placeholder="Type your message here..." + value={this._inputValue} + onChange={action(e => (this._inputValue = e.target.value))} + disabled={this._isLoading} + /> + </div> <button className="submit-button" onClick={() => this._dictation?.stopDictation()} type="submit" disabled={this._isLoading || !this._inputValue.trim()}> {this._isLoading ? ( <div className="spinner"></div> @@ -1028,9 +1471,42 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { {/* Popup for citation */} {this._citationPopup.visible && ( <div className="citation-popup"> - <p> - <strong>Text from your document: </strong> {this._citationPopup.text} - </p> + <div className="citation-popup-header"> + <strong>Text from your document</strong> + <button className="citation-close-button" onClick={this.closeCitationPopup}> + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + </div> + <div className="citation-content">{this._citationPopup.text}</div> + </div> + )} + + {/* Tool Reload Modal */} + {this._toolReloadModal.visible && ( + <div className="tool-reload-modal-overlay"> + <div className="tool-reload-modal"> + <div className="tool-reload-modal-header"> + <h3>Tool Created Successfully!</h3> + </div> + <div className="tool-reload-modal-content"> + <p> + The tool <strong>{this._toolReloadModal.toolName}</strong> has been created and saved successfully. + </p> + <p>To make the tool available for future use, the page needs to be reloaded to rebuild the application bundle.</p> + <p>Click "Reload Page" to complete the tool installation.</p> + </div> + <div className="tool-reload-modal-actions"> + <button className="reload-button primary" onClick={this.handleReloadConfirmation}> + Reload Page + </button> + <button className="close-button secondary" onClick={this.closeToolReloadModal}> + Later + </button> + </div> + </div> </div> )} </div> @@ -1043,5 +1519,5 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { */ Docs.Prototypes.TemplateMap.set(DocumentType.CHAT, { layout: { view: ChatBox, dataField: 'data' }, - options: { acl: '', _layout_fitWidth: true, chat: '', chat_history: '', chat_thread_id: '', chat_assistant_id: '', chat_vector_store_id: '' }, + options: { acl: '', _layout_fitWidth: true, chat: '', chat_history: '', chat_thread_id: '', chat_assistant_id: '', chat_vector_store_id: '', _forceActive: true }, }); |