diff options
Diffstat (limited to 'src')
5 files changed, 1032 insertions, 888 deletions
diff --git a/src/client/views/nodes/chatbot/agentsystem/Agent.ts b/src/client/views/nodes/chatbot/agentsystem/Agent.ts index 43faf5bf4..5af021dbf 100644 --- a/src/client/views/nodes/chatbot/agentsystem/Agent.ts +++ b/src/client/views/nodes/chatbot/agentsystem/Agent.ts @@ -20,10 +20,12 @@ import { getReactPrompt } from './prompts'; //import { DictionaryTool } from '../tools/DictionaryTool'; import { ChatCompletionMessageParam } from 'openai/resources'; import { Doc } from '../../../../../fields/Doc'; -import { parsedDoc } from '../chatboxcomponents/ChatBox'; +import { ChatBox, parsedDoc } from '../chatboxcomponents/ChatBox'; import { WebsiteInfoScraperTool } from '../tools/WebsiteInfoScraperTool'; import { Upload } from '../../../../../server/SharedMediaTypes'; import { RAGTool } from '../tools/RAGTool'; +import { AgentDocumentManager } from '../utils/AgentDocumentManager'; +import { CreateLinksTool } from '../tools/CreateLinksTool'; //import { CreateTextDocTool } from '../tools/CreateTextDocumentTool'; dotenv.config(); @@ -47,6 +49,7 @@ export class Agent { private processingInfo: ProcessingInfo[] = []; private streamedAnswerParser: StreamedAnswerParser = new StreamedAnswerParser(); private tools: Record<string, BaseTool<ReadonlyArray<Parameter>>>; + private _docManager: AgentDocumentManager; /** * The constructor initializes the agent with the vector store and toolset, and sets up the OpenAI client. @@ -64,9 +67,9 @@ export class Agent { csvData: () => { filename: string; id: string; text: string }[], addLinkedUrlDoc: (url: string, id: string) => void, createImage: (result: Upload.FileInformation & Upload.InspectionResults, options: DocumentOptions) => void, - addLinkedDoc: (doc: parsedDoc) => Doc | undefined, // eslint-disable-next-line @typescript-eslint/no-unused-vars - createCSVInDash: (url: string, title: string, id: string, data: string) => void + createCSVInDash: (url: string, title: string, id: string, data: string) => void, + chatBox: ChatBox ) { // Initialize OpenAI client with API key from environment this.client = new OpenAI({ apiKey: process.env.OPENAI_KEY, dangerouslyAllowBrowser: true }); @@ -74,6 +77,7 @@ export class Agent { this._history = history; this._summaries = summaries; this._csvData = csvData; + this._docManager = new AgentDocumentManager(chatBox); // Define available tools for the assistant this.tools = { @@ -84,7 +88,8 @@ export class Agent { searchTool: new SearchTool(addLinkedUrlDoc), noTool: new NoTool(), //imageCreationTool: new ImageCreationTool(createImage), - documentMetadata: new DocumentMetadataTool(this), + documentMetadata: new DocumentMetadataTool(this._docManager), + createLinks: new CreateLinksTool(this._docManager), }; } @@ -541,9 +546,9 @@ export class Agent { * * @param chatBox The ChatBox instance to pass to the DocumentMetadataTool */ - public reinitializeDocumentMetadataTool(chatBox: any): void { + public reinitializeDocumentMetadataTool(): void { if (this.tools && this.tools.documentMetadata) { - this.tools.documentMetadata = new DocumentMetadataTool(chatBox); + this.tools.documentMetadata = new DocumentMetadataTool(this._docManager); console.log('Agent: Reinitialized DocumentMetadataTool with ChatBox instance'); } else { console.warn('Agent: Could not reinitialize DocumentMetadataTool - tool not found'); diff --git a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx index 33419e559..e09b4313f 100644 --- a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx +++ b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx @@ -107,11 +107,11 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { 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.agent = new Agent(this.vectorstore, this.retrieveSummaries, this.retrieveFormattedHistory, this.retrieveCSVData, this.addLinkedUrlDoc, this.createImageInDash, this.createCSVInDash, this); // Reinitialize the DocumentMetadataTool with a direct reference to this ChatBox instance // This ensures the tool can properly access documents in the same Freeform view - this.agent.reinitializeDocumentMetadataTool(this); + this.agent.reinitializeDocumentMetadataTool(); this.messagesRef = React.createRef<HTMLDivElement>(); @@ -446,7 +446,7 @@ 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 = (() => { @@ -516,28 +516,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. diff --git a/src/client/views/nodes/chatbot/tools/CreateLinksTool.ts b/src/client/views/nodes/chatbot/tools/CreateLinksTool.ts new file mode 100644 index 000000000..c2850a8ce --- /dev/null +++ b/src/client/views/nodes/chatbot/tools/CreateLinksTool.ts @@ -0,0 +1,68 @@ +import { Observation } from '../types/types'; +import { ParametersType, ToolInfo } from '../types/tool_types'; +import { BaseTool } from './BaseTool'; +import { AgentDocumentManager } from '../utils/AgentDocumentManager'; + +const createLinksToolParams = [ + { + name: 'document_ids', + type: 'string[]', + description: 'List of document IDs to create links between. All documents will be linked to each other.', + required: true, + }, +] as const; + +type CreateLinksToolParamsType = typeof createLinksToolParams; + +const createLinksToolInfo: ToolInfo<CreateLinksToolParamsType> = { + name: 'createLinks', + description: 'Creates visual links between multiple documents in the dashboard. This allows related documents to be connected visually with lines that users can see.', + citationRules: 'No citation needed.', + parameterRules: createLinksToolParams, +}; + +export class CreateLinksTool extends BaseTool<CreateLinksToolParamsType> { + private _documentManager: AgentDocumentManager; + + constructor(documentManager: AgentDocumentManager) { + super(createLinksToolInfo); + this._documentManager = documentManager; + } + + async execute(args: ParametersType<CreateLinksToolParamsType>): Promise<Observation[]> { + try { + // Validate that we have at least 2 documents to link + if (args.document_ids.length < 2) { + return [{ type: 'text', text: 'Error: At least 2 document IDs are required to create links.' }]; + } + + // Validate that all documents exist + const missingDocIds = args.document_ids.filter(id => !this._documentManager.has(id)); + if (missingDocIds.length > 0) { + return [ + { + type: 'text', + text: `Error: The following document IDs were not found: ${missingDocIds.join(', ')}`, + }, + ]; + } + + // Create links between all documents with the specified relationship + const createdLinks = this._documentManager.addLinks(args.document_ids); + + return [ + { + type: 'text', + text: `Successfully created ${createdLinks.length} visual links between ${args.document_ids.length}.`, + }, + ]; + } catch (error) { + return [ + { + type: 'text', + text: `Error creating links: ${error instanceof Error ? error.message : String(error)}`, + }, + ]; + } + } +} diff --git a/src/client/views/nodes/chatbot/tools/DocumentMetadataTool.ts b/src/client/views/nodes/chatbot/tools/DocumentMetadataTool.ts index 08351143b..4b751acc0 100644 --- a/src/client/views/nodes/chatbot/tools/DocumentMetadataTool.ts +++ b/src/client/views/nodes/chatbot/tools/DocumentMetadataTool.ts @@ -10,6 +10,7 @@ import { LinkManager } from '../../../../util/LinkManager'; import { DocCast, StrCast } from '../../../../../fields/Types'; import { supportedDocTypes } from '../types/tool_types'; import { parsedDoc } from '../chatboxcomponents/ChatBox'; +import { AgentDocumentManager } from '../utils/AgentDocumentManager'; // Define the parameters for the DocumentMetadataTool const parameterDefinitions: ReadonlyArray<Parameter> = [ @@ -199,746 +200,12 @@ const documentMetadataToolInfo: ToolInfo<DocumentMetadataToolParamsType> = { * and allows for editing document fields in the correct location. */ export class DocumentMetadataTool extends BaseTool<DocumentMetadataToolParamsType> { - private freeformView: any; - private chatBox: any; - private chatBoxDocument: Doc | null = null; - private documentsById: Map<string, Doc> = new Map(); - private layoutDocsById: Map<string, Doc> = new Map(); - private dataDocsById: Map<string, Doc> = new Map(); - private fieldMetadata: Record<string, any> = {}; - private readonly DOCUMENT_ID_FIELD = '_dash_document_id'; - - constructor(chatBox: any) { - super(documentMetadataToolInfo); - this.chatBox = chatBox; - - // Store a direct reference to the ChatBox document - if (chatBox && chatBox.Document) { - this.chatBoxDocument = chatBox.Document; - if (this.chatBoxDocument && this.chatBoxDocument.id) { - console.log('DocumentMetadataTool initialized with ChatBox Document:', this.chatBoxDocument.id); - } else { - console.log('DocumentMetadataTool initialized with ChatBox Document (no ID)'); - } - } else if (chatBox && chatBox.props && chatBox.props.Document) { - this.chatBoxDocument = chatBox.props.Document; - if (this.chatBoxDocument && this.chatBoxDocument.id) { - console.log('DocumentMetadataTool initialized with ChatBox props.Document:', this.chatBoxDocument.id); - } else { - console.log('DocumentMetadataTool initialized with ChatBox props.Document (no ID)'); - } - } else { - console.warn('DocumentMetadataTool initialized without valid ChatBox Document reference'); - } - - this.initializeFieldMetadata(); - } - - /** - * Extracts field metadata from DocumentOptions class - */ - private initializeFieldMetadata() { - // Parse DocumentOptions to extract field definitions - const documentOptionsInstance = new DocumentOptions(); - const documentOptionsEntries = Object.entries(documentOptionsInstance); - - for (const [fieldName, fieldInfo] of documentOptionsEntries) { - // Extract field information - const fieldData: Record<string, any> = { - name: fieldName, - withoutUnderscore: fieldName.startsWith('_') ? fieldName.substring(1) : fieldName, - description: '', - type: 'unknown', - required: false, - defaultValue: undefined, - possibleValues: [], - }; - - // Check if fieldInfo has description property (it's likely a FInfo instance) - if (fieldInfo && typeof fieldInfo === 'object' && 'description' in fieldInfo) { - fieldData.description = fieldInfo.description; - - // Extract field type if available - if ('fieldType' in fieldInfo) { - fieldData.type = fieldInfo.fieldType; - } - - // Extract possible values if available - if ('values' in fieldInfo && Array.isArray(fieldInfo.values)) { - fieldData.possibleValues = fieldInfo.values; - } - } - - this.fieldMetadata[fieldName] = fieldData; - } - } - - /** - * Gets all documents in the same Freeform view as the ChatBox - * Uses the LinkManager to get all linked documents, similar to how ChatBox does it - */ - private findDocumentsInFreeformView() { - // Reset collections - this.documentsById.clear(); - this.layoutDocsById.clear(); - this.dataDocsById.clear(); - - try { - // Use the LinkManager approach which is proven to work in ChatBox - if (this.chatBoxDocument) { - console.log('Finding documents linked to ChatBox document with ID:', this.chatBoxDocument.id); - - // Get directly linked documents via LinkManager - const linkedDocs = LinkManager.Instance.getAllRelatedLinks(this.chatBoxDocument) - .map(d => DocCast(LinkManager.getOppositeAnchor(d, this.chatBoxDocument!))) - .map(d => DocCast(d?.annotationOn, d)) - .filter(d => d); - - console.log(`Found ${linkedDocs.length} linked documents via LinkManager`); - - // Process the linked documents - linkedDocs.forEach((doc: Doc) => { - if (doc) { - this.processDocument(doc); - } - }); - - // Include the ChatBox document itself - this.processDocument(this.chatBoxDocument); - - // If we have access to the Document's parent, try to find sibling documents - if (this.chatBoxDocument.parent) { - const parent = this.chatBoxDocument.parent; - console.log('Found parent document, checking for siblings'); - - // Check if parent is a Doc type and has a childDocs function - if (parent && typeof parent === 'object' && 'childDocs' in parent && typeof parent.childDocs === 'function') { - try { - const siblingDocs = parent.childDocs(); - if (Array.isArray(siblingDocs)) { - console.log(`Found ${siblingDocs.length} sibling documents via parent.childDocs()`); - siblingDocs.forEach((doc: Doc) => { - if (doc) { - this.processDocument(doc); - } - }); - } - } catch (e) { - console.warn('Error accessing parent.childDocs:', e); - } - } - } - } else if (this.chatBox && this.chatBox.linkedDocs) { - // If we have direct access to the linkedDocs computed property from ChatBox - console.log('Using ChatBox.linkedDocs directly'); - const linkedDocs = this.chatBox.linkedDocs; - if (Array.isArray(linkedDocs)) { - console.log(`Found ${linkedDocs.length} documents via ChatBox.linkedDocs`); - linkedDocs.forEach((doc: Doc) => { - if (doc) { - this.processDocument(doc); - } - }); - } - - // Process the ChatBox document if available - if (this.chatBox.Document) { - this.processDocument(this.chatBox.Document); - } - } else { - console.warn('No ChatBox document reference available for finding linked documents'); - } - - console.log(`DocumentMetadataTool found ${this.documentsById.size} total documents`); - - // If we didn't find any documents, try a fallback method - if (this.documentsById.size === 0 && this.chatBox) { - console.log('No documents found, trying fallback method'); - - // Try to access any field that might contain documents - if (this.chatBox.props && this.chatBox.props.documents) { - const documents = this.chatBox.props.documents; - if (Array.isArray(documents)) { - console.log(`Found ${documents.length} documents via ChatBox.props.documents`); - documents.forEach((doc: Doc) => { - if (doc) { - this.processDocument(doc); - } - }); - } - } - } - } catch (error) { - console.error('Error finding documents in Freeform view:', error); - } - } - - /** - * Process a document by ensuring it has an ID and adding it to the appropriate collections - * @param doc The document to process - */ - private processDocument(doc: Doc) { - // Ensure document has a persistent ID - const docId = this.ensureDocumentId(doc); - - // Only add if we haven't already processed this document - if (!this.documentsById.has(docId)) { - this.documentsById.set(docId, doc); - - // Get layout doc (the document itself or its layout) - // TODO: Check if this works. - const layoutDoc = doc; - if (layoutDoc) { - this.layoutDocsById.set(docId, layoutDoc); - } - - // Get data doc - const dataDoc = doc[DocData]; - if (dataDoc) { - this.dataDocsById.set(docId, dataDoc); - } - } - } - - /** - * Ensures a document has a persistent ID stored in its metadata - * @param doc The document to ensure has an ID - * @returns The document's ID - */ - private ensureDocumentId(doc: Doc): string { - let docId: string | undefined; - - // First try to get the ID from our custom field - if (doc[this.DOCUMENT_ID_FIELD]) { - docId = String(doc[this.DOCUMENT_ID_FIELD]); - return docId; - } - - // Try different ways to get a document ID - - // 1. Try the direct id property if it exists - if (doc.id && typeof doc.id === 'string') { - docId = doc.id; - } - // 2. Try doc._id if it exists - else if (doc._id && typeof doc._id === 'string') { - docId = doc._id; - } - // 3. Try doc.data?.id if it exists - else if (doc.data && typeof doc.data === 'object' && 'id' in doc.data && typeof doc.data.id === 'string') { - docId = doc.data.id; - } - // 4. If none of the above work, generate a UUID - else { - docId = uuidv4(); - console.log(`Generated new UUID for document with title: ${doc.title || 'Untitled'}`); - } - - // Store the ID in the document's metadata so it persists - try { - doc[this.DOCUMENT_ID_FIELD] = docId; - } catch (e) { - console.warn(`Could not assign ID to document property`, e); - } - - return docId; - } - - /** - * Extracts metadata from a specific document - * @param docId The ID of the document to extract metadata from - * @returns An object containing the document's metadata - */ - private extractDocumentMetadata(doc?: Doc) { - if (!doc) return null; - const layoutDoc = doc; - const dataDoc = doc[DocData]; - - const metadata: Record<string, any> = { - id: doc.dash_document_id || doc.id || '', - title: doc.title || '', - type: doc.type || '', - fields: { - layout: {}, - data: {}, - }, - fieldLocationMap: {}, - }; - - // Process all known field definitions - Object.keys(this.fieldMetadata).forEach(fieldName => { - const fieldDef = this.fieldMetadata[fieldName]; - const strippedName = fieldName.startsWith('_') ? fieldName.substring(1) : fieldName; - - // Check if field exists on layout document - let layoutValue = undefined; - if (layoutDoc) { - layoutValue = layoutDoc[fieldName]; - if (layoutValue !== undefined) { - // Field exists on layout document - metadata.fields.layout[fieldName] = this.formatFieldValue(layoutValue); - metadata.fieldLocationMap[strippedName] = 'layout'; - } - } - - // Check if field exists on data document - let dataValue = undefined; - if (dataDoc) { - dataValue = dataDoc[fieldName]; - if (dataValue !== undefined) { - // Field exists on data document - metadata.fields.data[fieldName] = this.formatFieldValue(dataValue); - if (!metadata.fieldLocationMap[strippedName]) { - metadata.fieldLocationMap[strippedName] = 'data'; - } - } - } - - // For fields with stripped names (without leading underscore), - // also check if they exist on documents without the underscore - if (fieldName.startsWith('_')) { - const nonUnderscoreFieldName = fieldName.substring(1); - - if (layoutDoc) { - const nonUnderscoreLayoutValue = layoutDoc[nonUnderscoreFieldName]; - if (nonUnderscoreLayoutValue !== undefined) { - metadata.fields.layout[nonUnderscoreFieldName] = this.formatFieldValue(nonUnderscoreLayoutValue); - metadata.fieldLocationMap[nonUnderscoreFieldName] = 'layout'; - } - } - - if (dataDoc) { - const nonUnderscoreDataValue = dataDoc[nonUnderscoreFieldName]; - if (nonUnderscoreDataValue !== undefined) { - metadata.fields.data[nonUnderscoreFieldName] = this.formatFieldValue(nonUnderscoreDataValue); - if (!metadata.fieldLocationMap[nonUnderscoreFieldName]) { - metadata.fieldLocationMap[nonUnderscoreFieldName] = 'data'; - } - } - } - } - }); - - // Add common field aliases for easier discovery - // This helps users understand both width and _width refer to the same property - if (metadata.fields.layout._width !== undefined && metadata.fields.layout.width === undefined) { - metadata.fields.layout.width = metadata.fields.layout._width; - metadata.fieldLocationMap.width = 'layout'; - } - - if (metadata.fields.layout._height !== undefined && metadata.fields.layout.height === undefined) { - metadata.fields.layout.height = metadata.fields.layout._height; - metadata.fieldLocationMap.height = 'layout'; - } - - return metadata; - } + private _docManager: AgentDocumentManager; - /** - * Edits a specific field on a document - * @param docId The ID of the document to edit - * @param fieldName The name of the field to edit - * @param fieldValue The new value for the field (string, number, or boolean) - * @returns Object with success status, message, and additional information - */ - private editDocumentField( - docId: string, - fieldName: string, - fieldValue: string | number | boolean - ): { - success: boolean; - message: string; - fieldName?: string; - originalFieldName?: string; - newValue?: any; - warning?: string; - } { - // Normalize field name (handle with/without underscore) - let normalizedFieldName = fieldName.startsWith('_') ? fieldName : fieldName; - const strippedFieldName = fieldName.startsWith('_') ? fieldName.substring(1) : fieldName; - - // Handle common field name aliases (width → _width, height → _height) - // Many document fields use '_' prefix for layout properties - if (fieldName === 'width') { - normalizedFieldName = '_width'; - } else if (fieldName === 'height') { - normalizedFieldName = '_height'; - } - - // Get the documents - const doc = this.documentsById.get(docId); - if (!doc) { - return { success: false, message: `Document with ID ${docId} not found` }; - } - - const layoutDoc = this.layoutDocsById.get(docId); - const dataDoc = this.dataDocsById.get(docId); - - if (!layoutDoc && !dataDoc) { - return { success: false, message: `Could not find layout or data document for document with ID ${docId}` }; - } - - try { - // Convert the field value to the appropriate type based on field metadata - const convertedValue = this.convertFieldValue(normalizedFieldName, fieldValue); - - let targetDoc: Doc | undefined; - let targetLocation: string; - - // First, check if field exists on layout document using Doc.Get - if (layoutDoc) { - const fieldExistsOnLayout = Doc.Get(layoutDoc, normalizedFieldName, true) !== undefined; - - // If it exists on layout document, update it there - if (fieldExistsOnLayout) { - targetDoc = layoutDoc; - targetLocation = 'layout'; - } - // If it has an underscore prefix, it's likely a layout property even if not yet set - else if (normalizedFieldName.startsWith('_')) { - targetDoc = layoutDoc; - targetLocation = 'layout'; - } - // Otherwise, look for or create on data document - else if (dataDoc) { - targetDoc = dataDoc; - targetLocation = 'data'; - } - // If no data document available, default to layout - else { - targetDoc = layoutDoc; - targetLocation = 'layout'; - } - } - // If no layout document, use data document - else if (dataDoc) { - targetDoc = dataDoc; - targetLocation = 'data'; - } else { - return { success: false, message: `No valid document found for editing` }; - } - - if (!targetDoc) { - return { success: false, message: `Target document not available` }; - } - - // Set the field value on the target document - targetDoc[normalizedFieldName] = convertedValue; - - return { - success: true, - message: `Successfully updated field '${normalizedFieldName}' on ${targetLocation} document (ID: ${docId})`, - fieldName: normalizedFieldName, - originalFieldName: fieldName, - newValue: convertedValue, - }; - } catch (error) { - console.error('Error editing document field:', error); - return { - success: false, - message: `Error updating field: ${error instanceof Error ? error.message : String(error)}`, - }; - } - } - - /** - * Converts a string field value to the appropriate type based on field metadata - * @param fieldName The name of the field - * @param fieldValue The string value to convert - * @returns The converted value with the appropriate type - */ - private convertFieldValue(fieldName: string, fieldValue: any): any { - // If fieldValue is already a number or boolean, we don't need to convert it from string - if (typeof fieldValue === 'number' || typeof fieldValue === 'boolean') { - return fieldValue; - } - - // If fieldValue is a string "true" or "false", convert to boolean - if (typeof fieldValue === 'string') { - if (fieldValue.toLowerCase() === 'true') { - return true; - } - if (fieldValue.toLowerCase() === 'false') { - return false; - } - } - - // If fieldValue is not a string (and not a number or boolean), convert it to string - if (typeof fieldValue !== 'string') { - fieldValue = String(fieldValue); - } - - // Special handling for text field - convert to proper RichTextField format - if (fieldName === 'text') { - try { - // Check if it's already a valid JSON RichTextField - JSON.parse(fieldValue); - return fieldValue; - } catch (e) { - // It's a plain text string, so convert it to RichTextField format - const rtf = { - doc: { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: fieldValue, - }, - ], - }, - ], - }, - }; - return JSON.stringify(rtf); - } - } - - // Get field metadata - const normalizedFieldName = fieldName.startsWith('_') ? fieldName : `_${fieldName}`; - const strippedFieldName = fieldName.startsWith('_') ? fieldName.substring(1) : fieldName; - - // Check both versions of the field name in metadata - const fieldMeta = this.fieldMetadata[normalizedFieldName] || this.fieldMetadata[strippedFieldName]; - - // Special handling for width and height without metadata - if (!fieldMeta && (fieldName === '_width' || fieldName === '_height' || fieldName === 'width' || fieldName === 'height')) { - const num = Number(fieldValue); - return isNaN(num) ? fieldValue : num; - } - - if (!fieldMeta) { - // If no metadata found, just return the string value - return fieldValue; - } - - // Convert based on field type - const fieldType = fieldMeta.type; - - if (fieldType === 'boolean') { - // Convert to boolean - return fieldValue.toLowerCase() === 'true'; - } else if (fieldType === 'number') { - // Convert to number - const num = Number(fieldValue); - return isNaN(num) ? fieldValue : num; - } else if (fieldType === 'date') { - // Try to convert to date (stored as number timestamp) - try { - return new Date(fieldValue).getTime(); - } catch (e) { - return fieldValue; - } - } else if (fieldType.includes('list') || fieldType.includes('array')) { - // Try to parse as JSON array - try { - return JSON.parse(fieldValue); - } catch (e) { - return fieldValue; - } - } else if (fieldType === 'json' || fieldType === 'object') { - // Try to parse as JSON object - try { - return JSON.parse(fieldValue); - } catch (e) { - return fieldValue; - } - } - - // Default to string - return fieldValue; - } - - /** - * Formats a field value for JSON output - * @param value The field value to format - * @returns A JSON-friendly representation of the field value - */ - private formatFieldValue(value: any): any { - if (value === undefined || value === null) { - return null; - } - - // Handle Doc objects - if (value instanceof Doc) { - return { - type: 'Doc', - id: value.id || this.ensureDocumentId(value), - title: value.title || '', - docType: value.type || '', - }; - } - - // Handle RichTextField (try to extract plain text) - if (typeof value === 'string' && value.includes('"type":"doc"') && value.includes('"content":')) { - try { - const rtfObj = JSON.parse(value); - // If this looks like a rich text field structure - if (rtfObj.doc && rtfObj.doc.content) { - // Recursively extract text from the content - let plainText = ''; - const extractText = (node: any) => { - if (node.text) { - plainText += node.text; - } - if (node.content && Array.isArray(node.content)) { - node.content.forEach((child: any) => extractText(child)); - } - }; - - extractText(rtfObj.doc); - - // If we successfully extracted text, show it, but also preserve the original value - if (plainText) { - return { - type: 'RichText', - text: plainText, - length: plainText.length, - // Don't include the full value as it can be very large - }; - } - } - } catch (e) { - // If parsing fails, just treat as a regular string - } - } - - // Handle arrays and complex objects - if (typeof value === 'object') { - // If the object has a toString method, use it - if (value.toString && value.toString !== Object.prototype.toString) { - return value.toString(); - } - - try { - // Try to convert to JSON string - return JSON.stringify(value); - } catch (e) { - return '[Complex Object]'; - } - } - - // Return primitive values as is - return value; - } - - /** - * Extracts all field metadata from DocumentOptions - * @returns A structured object containing metadata about all available document fields - */ - private getAllFieldMetadata() { - // Start with our already populated fieldMetadata from the DocumentOptions class - const result: Record<string, any> = { - fieldCount: Object.keys(this.fieldMetadata).length, - fields: {}, - fieldsByType: { - string: [], - number: [], - boolean: [], - //doc: [], - //list: [], - //date: [], - //enumeration: [], - //other: [], - }, - fieldNameMappings: {}, - commonFields: { - appearance: [], - position: [], - size: [], - content: [], - behavior: [], - layout: [], - }, - }; - - // Process each field in the metadata - Object.entries(this.fieldMetadata).forEach(([fieldName, fieldInfo]) => { - const strippedName = fieldName.startsWith('_') ? fieldName.substring(1) : fieldName; - - // Add to fieldNameMappings - if (fieldName.startsWith('_')) { - result.fieldNameMappings[strippedName] = fieldName; - } - - // Create structured field metadata - const fieldData: Record<string, any> = { - name: fieldName, - displayName: strippedName, - description: fieldInfo.description || '', - type: fieldInfo.fieldType || 'unknown', - possibleValues: fieldInfo.values || [], - }; - - // Add field to fields collection - result.fields[fieldName] = fieldData; - - // Categorize by field type - const type = fieldInfo.fieldType?.toLowerCase() || 'unknown'; - if (type === 'string') { - result.fieldsByType.string.push(fieldName); - } else if (type === 'number') { - result.fieldsByType.number.push(fieldName); - } else if (type === 'boolean') { - result.fieldsByType.boolean.push(fieldName); - } else if (type === 'doc') { - //result.fieldsByType.doc.push(fieldName); - } else if (type === 'list') { - //result.fieldsByType.list.push(fieldName); - } else if (type === 'date') { - //result.fieldsByType.date.push(fieldName); - } else if (type === 'enumeration') { - //result.fieldsByType.enumeration.push(fieldName); - } else { - //result.fieldsByType.other.push(fieldName); - } - - // Categorize by field purpose - if (fieldName.includes('width') || fieldName.includes('height') || fieldName.includes('size')) { - result.commonFields.size.push(fieldName); - } else if (fieldName.includes('color') || fieldName.includes('background') || fieldName.includes('border')) { - result.commonFields.appearance.push(fieldName); - } else if (fieldName.includes('x') || fieldName.includes('y') || fieldName.includes('position') || fieldName.includes('pan')) { - result.commonFields.position.push(fieldName); - } else if (fieldName.includes('text') || fieldName.includes('title') || fieldName.includes('data')) { - result.commonFields.content.push(fieldName); - } else if (fieldName.includes('action') || fieldName.includes('click') || fieldName.includes('event')) { - result.commonFields.behavior.push(fieldName); - } else if (fieldName.includes('layout')) { - result.commonFields.layout.push(fieldName); - } - }); - - // Add special section for auto-sizing related fields - result.autoSizingFields = { - height: { - autoHeightField: '_layout_autoHeight', - heightField: '_height', - displayName: 'height', - usage: 'To manually set height, first set layout_autoHeight to false', - }, - width: { - autoWidthField: '_layout_autoWidth', - widthField: '_width', - displayName: 'width', - usage: 'To manually set width, first set layout_autoWidth to false', - }, - }; - - // Add special section for text field format - result.specialFields = { - text: { - name: 'text', - description: 'Document text content', - format: 'RichTextField', - note: 'When setting text, provide plain text - it will be automatically converted to the correct format', - example: 'For setting: "Hello world" (plain text); For getting: Will be converted to plaintext for display', - }, - }; - - return result; + constructor(docManager: AgentDocumentManager) { + super(documentMetadataToolInfo); + this._docManager = docManager; + this._docManager.initializeFindDocsFreeform(); } /** @@ -950,7 +217,7 @@ export class DocumentMetadataTool extends BaseTool<DocumentMetadataToolParamsTyp console.log('DocumentMetadataTool: Executing with args:', args); // Find all documents in the Freeform view - this.findDocumentsInFreeformView(); + this._docManager.initializeFindDocsFreeform(); try { // Validate required input parameters based on action @@ -981,7 +248,7 @@ export class DocumentMetadataTool extends BaseTool<DocumentMetadataToolParamsTyp switch (action) { case 'get': { // Get metadata for a specific document or all documents - const result = this.getDocumentMetadata(documentId); + const result = this._docManager.getDocumentMetadata(documentId); console.log('DocumentMetadataTool: Get metadata result:', result); return [ { @@ -1003,7 +270,7 @@ export class DocumentMetadataTool extends BaseTool<DocumentMetadataToolParamsTyp } // Ensure document exists - if (!this.documentsById.has(documentId)) { + if (!this._docManager.has(documentId)) { return [ { type: 'text', @@ -1059,7 +326,7 @@ export class DocumentMetadataTool extends BaseTool<DocumentMetadataToolParamsTyp const fieldName = String(edit.fieldName); // Edit the field - const result = this.editDocumentField(documentId, fieldName, fieldValue); + const result = this._docManager.editDocumentField(documentId, fieldName, fieldValue); console.log(`DocumentMetadataTool: Edit field result for ${fieldName}:`, result); @@ -1101,7 +368,7 @@ export class DocumentMetadataTool extends BaseTool<DocumentMetadataToolParamsTyp } // Get the updated metadata to return - const updatedMetadata = this.getDocumentMetadata(documentId); + const updatedMetadata = this._docManager.getDocumentMetadata(documentId); return [ { @@ -1120,33 +387,12 @@ export class DocumentMetadataTool extends BaseTool<DocumentMetadataToolParamsTyp } case 'list': { - // List all available documents in simple format - const docs = Array.from(this.documentsById.entries()).map(([id, doc]) => ({ - id, - title: doc.title || 'Untitled Document', - type: doc.type || 'Unknown Type', - })); - - if (docs.length === 0) { - return [ - { - type: 'text', - text: 'No documents found in the current view.', - }, - ]; - } - - return [ - { - type: 'text', - text: `Found ${docs.length} document(s) in the current view:\n${JSON.stringify(docs, null, 2)}`, - }, - ]; + this._docManager.listDocs(); } case 'getFieldOptions': { // Get all available field options with metadata - const fieldOptions = this.getAllFieldMetadata(); + const fieldOptions = this._docManager.getAllFieldMetadata(); return [ { @@ -1171,61 +417,27 @@ export class DocumentMetadataTool extends BaseTool<DocumentMetadataToolParamsTyp const title = String(args.title); const data = String(args.data); - // Validate doc_type - if (!this.isValidDocType(docType)) { + const createdDoc = this._docManager.createDocInDash(docType, title, data); + + if (!createdDoc) { return [ { type: 'text', - text: `Error: Invalid doc_type. Valid options are: ${Object.keys(supportedDocTypes).join(',')}`, + text: 'Error: Failed to create document.', }, ]; } - try { - // Create simple document with just title and data - const simpleDoc: parsedDoc = { - doc_type: docType, - title: title, - data: data, - x: 0, - y: 0, - _width: 300, - _height: 300, - _layout_fitWidth: false, - _layout_autoHeight: true, - }; - - // Use the chatBox's createDocInDash method to create and link the document - if (!this.chatBox || !this.chatBox.createDocInDash) { - return [ - { - type: 'text', - text: 'Error: Could not access document creation functionality.', - }, - ]; - } - - const createdDoc = this.chatBox.createDocInDash(simpleDoc); - - if (!createdDoc) { - return [ - { - type: 'text', - text: 'Error: Failed to create document.', - }, - ]; - } + // Update our local document maps with the new document + this._docManager.processDocument(createdDoc); - // Update our local document maps with the new document - this.processDocument(createdDoc); + // Get the created document's metadata + const createdMetadata = this._docManager.extractDocumentMetadata(this._docManager.createAgentDoc(createdDoc)); - // Get the created document's metadata - const createdMetadata = this.extractDocumentMetadata(createdDoc); - - return [ - { - type: 'text', - text: `Document created successfully. + return [ + { + type: 'text', + text: `Document created successfully. Document ID: ${createdDoc.id} Type: ${docType} Title: "${title}" @@ -1241,16 +453,8 @@ Next steps: Full metadata for the created document: ${JSON.stringify(createdMetadata, null, 2)}`, - }, - ]; - } catch (error) { - return [ - { - type: 'text', - text: `Error creating document: ${error instanceof Error ? error.message : String(error)}`, - }, - ]; - } + }, + ]; } default: @@ -1370,38 +574,4 @@ ${JSON.stringify(createdMetadata, null, 2)}`, return `Unknown action "${action}". Valid actions are "get", "edit", "list", "getFieldOptions", or "create".`; } } - - /** - * Gets metadata for a specific document or all documents - * @param documentId Optional ID of a specific document to get metadata for - * @returns Document metadata or metadata for all documents - */ - private getDocumentMetadata(documentId?: string): any { - if (documentId) { - const doc = this.documentsById.get(documentId); - // Get metadata for a specific document - return this.extractDocumentMetadata(doc); - } else { - // Get metadata for all documents - const documentsMetadata: Record<string, any> = {}; - for (const doc of this.documentsById.values()) { - documentsMetadata.add(this.extractDocumentMetadata(doc)); - } - - return { - documentCount: this.documentsById.size, - documents: documentsMetadata, - fieldDefinitions: this.fieldMetadata, - }; - } - } - - /** - * Helper method to validate a document type and ensure it's a valid supportedDocType - * @param docType The document type to validate - * @returns True if the document type is valid, false otherwise - */ - private isValidDocType(docType: string): boolean { - return Object.values(supportedDocTypes).includes(docType as supportedDocTypes); - } } diff --git a/src/client/views/nodes/chatbot/utils/AgentDocumentManager.ts b/src/client/views/nodes/chatbot/utils/AgentDocumentManager.ts new file mode 100644 index 000000000..c954226e4 --- /dev/null +++ b/src/client/views/nodes/chatbot/utils/AgentDocumentManager.ts @@ -0,0 +1,923 @@ +import { ChatBox } from '../chatboxcomponents/ChatBox'; +import { Doc, FieldType, Opt } from '../../../../../fields/Doc'; +import { DocData } from '../../../../../fields/DocSymbols'; +import { Observation } from '../types/types'; +import { ParametersType, ToolInfo, Parameter } from '../types/tool_types'; +import { BaseTool } from '../tools/BaseTool'; +import { Docs, DocumentOptions } from '../../../../documents/Documents'; +import { CollectionFreeFormDocumentView } from '../../CollectionFreeFormDocumentView'; +import { v4 as uuidv4 } from 'uuid'; +import { LinkManager, UPDATE_SERVER_CACHE } from '../../../../util/LinkManager'; +import { DocCast, StrCast } from '../../../../../fields/Types'; +import { supportedDocTypes } from '../types/tool_types'; +import { parsedDoc } from '../chatboxcomponents/ChatBox'; +import { faThumbTackSlash } from '@fortawesome/free-solid-svg-icons'; +import { DocumentManager } from '../../../../util/DocumentManager'; +import { DocumentView } from '../../DocumentView'; + +/** + * Interface representing a document in the freeform view + */ +interface AgentDocument { + layoutDoc: Doc; + dataDoc: Doc; +} + +/** + * Class to manage documents in a freeform view + */ +export class AgentDocumentManager { + private documentsById: Map<string, AgentDocument>; + private chatBox: ChatBox; + private chatBoxDocument: Doc | null = null; + private fieldMetadata: Record<string, any> = {}; + private readonly DOCUMENT_ID_FIELD = '_dash_document_id'; + + /** + * Creates a new DocumentManager + * @param templateDocument The document that serves as a template for new documents + */ + constructor(chatBox: ChatBox) { + this.documentsById = new Map<string, AgentDocument>(); + this.chatBox = chatBox; + this.chatBoxDocument = chatBox.Document; + this.processDocument(this.chatBoxDocument); + this.initializeFieldMetadata(); + } + + /** + * Extracts field metadata from DocumentOptions class + */ + private initializeFieldMetadata() { + // Parse DocumentOptions to extract field definitions + const documentOptionsInstance = new DocumentOptions(); + const documentOptionsEntries = Object.entries(documentOptionsInstance); + + for (const [fieldName, fieldInfo] of documentOptionsEntries) { + // Extract field information + const fieldData: Record<string, any> = { + name: fieldName, + withoutUnderscore: fieldName.startsWith('_') ? fieldName.substring(1) : fieldName, + description: '', + type: 'unknown', + required: false, + defaultValue: undefined, + possibleValues: [], + }; + + // Check if fieldInfo has description property (it's likely a FInfo instance) + if (fieldInfo && typeof fieldInfo === 'object' && 'description' in fieldInfo) { + fieldData.description = fieldInfo.description; + + // Extract field type if available + if ('fieldType' in fieldInfo) { + fieldData.type = fieldInfo.fieldType; + } + + // Extract possible values if available + if ('values' in fieldInfo && Array.isArray(fieldInfo.values)) { + fieldData.possibleValues = fieldInfo.values; + } + } + + this.fieldMetadata[fieldName] = fieldData; + } + } + + /** + * Gets all documents in the same Freeform view as the ChatBox + * Uses the LinkManager to get all linked documents, similar to how ChatBox does it + */ + public initializeFindDocsFreeform() { + // Reset collections + this.documentsById.clear(); + + try { + // Use the LinkManager approach which is proven to work in ChatBox + if (this.chatBoxDocument) { + console.log('Finding documents linked to ChatBox document with ID:', this.chatBoxDocument.id); + + // Get directly linked documents via LinkManager + const linkedDocs = LinkManager.Instance.getAllRelatedLinks(this.chatBoxDocument) + .map(d => DocCast(LinkManager.getOppositeAnchor(d, this.chatBoxDocument!))) + .map(d => DocCast(d?.annotationOn, d)) + .filter(d => d); + + console.log(`Found ${linkedDocs.length} linked documents via LinkManager`); + + // Process the linked documents + linkedDocs.forEach((doc: Doc) => { + if (doc) { + this.processDocument(doc); + } + }); + + // Include the ChatBox document itself + this.processDocument(this.chatBoxDocument); + + // If we have access to the Document's parent, try to find sibling documents + if (this.chatBoxDocument.parent) { + const parent = this.chatBoxDocument.parent; + console.log('Found parent document, checking for siblings'); + + // Check if parent is a Doc type and has a childDocs function + if (parent && typeof parent === 'object' && 'childDocs' in parent && typeof parent.childDocs === 'function') { + try { + const siblingDocs = parent.childDocs(); + if (Array.isArray(siblingDocs)) { + console.log(`Found ${siblingDocs.length} sibling documents via parent.childDocs()`); + siblingDocs.forEach((doc: Doc) => { + if (doc) { + this.processDocument(doc); + } + }); + } + } catch (e) { + console.warn('Error accessing parent.childDocs:', e); + } + } + } + } else if (this.chatBox && this.chatBox.linkedDocs) { + // If we have direct access to the linkedDocs computed property from ChatBox + console.log('Using ChatBox.linkedDocs directly'); + const linkedDocs = this.chatBox.linkedDocs; + if (Array.isArray(linkedDocs)) { + console.log(`Found ${linkedDocs.length} documents via ChatBox.linkedDocs`); + linkedDocs.forEach((doc: Doc) => { + if (doc) { + this.processDocument(doc); + } + }); + } + + // Process the ChatBox document if available + if (this.chatBox.Document) { + this.processDocument(this.chatBox.Document); + } + } else { + console.warn('No ChatBox document reference available for finding linked documents'); + } + + console.log(`DocumentMetadataTool found ${this.documentsById.size} total documents`); + } catch (error) { + console.error('Error finding documents in Freeform view:', error); + } + } + + /** + * Process a document by ensuring it has an ID and adding it to the appropriate collections + * @param doc The document to process + */ + public processDocument(doc: Doc) { + // Ensure document has a persistent ID + const docId = this.ensureDocumentId(doc); + // Only add if we haven't already processed this document + if (!this.documentsById.has(docId)) { + this.documentsById.set(docId, { layoutDoc: doc, dataDoc: doc[DocData] }); + } + } + + /** + * Ensures a document has a persistent ID stored in its metadata + * @param doc The document to ensure has an ID + * @returns The document's ID + */ + private ensureDocumentId(doc: Doc): string { + let docId: string | undefined; + + // First try to get the ID from our custom field + if (doc[this.DOCUMENT_ID_FIELD]) { + docId = String(doc[this.DOCUMENT_ID_FIELD]); + return docId; + } + + // Try different ways to get a document ID + + // 1. Try the direct id property if it exists + if (doc.id && typeof doc.id === 'string') { + docId = doc.id; + } + // 2. Try doc._id if it exists + else if (doc._id && typeof doc._id === 'string') { + docId = doc._id; + } + // 3. Try doc.data?.id if it exists + else if (doc.data && typeof doc.data === 'object' && 'id' in doc.data && typeof doc.data.id === 'string') { + docId = doc.data.id; + } + // 4. If none of the above work, generate a UUID + else { + docId = uuidv4(); + console.log(`Generated new UUID for document with title: ${doc.title || 'Untitled'}`); + } + + // Store the ID in the document's metadata so it persists + try { + doc[this.DOCUMENT_ID_FIELD] = docId; + } catch (e) { + console.warn(`Could not assign ID to document property`, e); + } + + return docId; + } + + /** + * Extracts metadata from a specific document + * @param docId The ID of the document to extract metadata from + * @returns An object containing the document's metadata + */ + public extractDocumentMetadata(doc?: AgentDocument) { + if (!doc) return null; + const layoutDoc = doc.layoutDoc; + const dataDoc = doc.dataDoc; + + const metadata: Record<string, any> = { + id: layoutDoc.dash_document_id || layoutDoc.id || '', + title: layoutDoc.title || '', + type: layoutDoc.type || '', + fields: { + layout: {}, + data: {}, + }, + fieldLocationMap: {}, + }; + + // Process all known field definitions + Object.keys(this.fieldMetadata).forEach(fieldName => { + const fieldDef = this.fieldMetadata[fieldName]; + const strippedName = fieldName.startsWith('_') ? fieldName.substring(1) : fieldName; + + // Check if field exists on layout document + let layoutValue = undefined; + if (layoutDoc) { + layoutValue = layoutDoc[fieldName]; + if (layoutValue !== undefined) { + // Field exists on layout document + metadata.fields.layout[fieldName] = this.formatFieldValue(layoutValue); + metadata.fieldLocationMap[strippedName] = 'layout'; + } + } + + // Check if field exists on data document + let dataValue = undefined; + if (dataDoc) { + dataValue = dataDoc[fieldName]; + if (dataValue !== undefined) { + // Field exists on data document + metadata.fields.data[fieldName] = this.formatFieldValue(dataValue); + if (!metadata.fieldLocationMap[strippedName]) { + metadata.fieldLocationMap[strippedName] = 'data'; + } + } + } + + // For fields with stripped names (without leading underscore), + // also check if they exist on documents without the underscore + if (fieldName.startsWith('_')) { + const nonUnderscoreFieldName = fieldName.substring(1); + + if (layoutDoc) { + const nonUnderscoreLayoutValue = layoutDoc[nonUnderscoreFieldName]; + if (nonUnderscoreLayoutValue !== undefined) { + metadata.fields.layout[nonUnderscoreFieldName] = this.formatFieldValue(nonUnderscoreLayoutValue); + metadata.fieldLocationMap[nonUnderscoreFieldName] = 'layout'; + } + } + + if (dataDoc) { + const nonUnderscoreDataValue = dataDoc[nonUnderscoreFieldName]; + if (nonUnderscoreDataValue !== undefined) { + metadata.fields.data[nonUnderscoreFieldName] = this.formatFieldValue(nonUnderscoreDataValue); + if (!metadata.fieldLocationMap[nonUnderscoreFieldName]) { + metadata.fieldLocationMap[nonUnderscoreFieldName] = 'data'; + } + } + } + } + }); + + // Add common field aliases for easier discovery + // This helps users understand both width and _width refer to the same property + if (metadata.fields.layout._width !== undefined && metadata.fields.layout.width === undefined) { + metadata.fields.layout.width = metadata.fields.layout._width; + metadata.fieldLocationMap.width = 'layout'; + } + + if (metadata.fields.layout._height !== undefined && metadata.fields.layout.height === undefined) { + metadata.fields.layout.height = metadata.fields.layout._height; + metadata.fieldLocationMap.height = 'layout'; + } + + return metadata; + } + + /** + * Formats a field value for JSON output + * @param value The field value to format + * @returns A JSON-friendly representation of the field value + */ + private formatFieldValue(value: any): any { + if (value === undefined || value === null) { + return null; + } + + // Handle Doc objects + if (value instanceof Doc) { + return { + type: 'Doc', + id: value.id || this.ensureDocumentId(value), + title: value.title || '', + docType: value.type || '', + }; + } + + // Handle RichTextField (try to extract plain text) + if (typeof value === 'string' && value.includes('"type":"doc"') && value.includes('"content":')) { + try { + const rtfObj = JSON.parse(value); + // If this looks like a rich text field structure + if (rtfObj.doc && rtfObj.doc.content) { + // Recursively extract text from the content + let plainText = ''; + const extractText = (node: any) => { + if (node.text) { + plainText += node.text; + } + if (node.content && Array.isArray(node.content)) { + node.content.forEach((child: any) => extractText(child)); + } + }; + + extractText(rtfObj.doc); + + // If we successfully extracted text, show it, but also preserve the original value + if (plainText) { + return { + type: 'RichText', + text: plainText, + length: plainText.length, + // Don't include the full value as it can be very large + }; + } + } + } catch (e) { + // If parsing fails, just treat as a regular string + } + } + + // Handle arrays and complex objects + if (typeof value === 'object') { + // If the object has a toString method, use it + if (value.toString && value.toString !== Object.prototype.toString) { + return value.toString(); + } + + try { + // Try to convert to JSON string + return JSON.stringify(value); + } catch (e) { + return '[Complex Object]'; + } + } + + // Return primitive values as is + return value; + } + + /** + * Converts a string field value to the appropriate type based on field metadata + * @param fieldName The name of the field + * @param fieldValue The string value to convert + * @returns The converted value with the appropriate type + */ + private convertFieldValue(fieldName: string, fieldValue: any): any { + // If fieldValue is already a number or boolean, we don't need to convert it from string + if (typeof fieldValue === 'number' || typeof fieldValue === 'boolean') { + return fieldValue; + } + + // If fieldValue is a string "true" or "false", convert to boolean + if (typeof fieldValue === 'string') { + if (fieldValue.toLowerCase() === 'true') { + return true; + } + if (fieldValue.toLowerCase() === 'false') { + return false; + } + } + + // If fieldValue is not a string (and not a number or boolean), convert it to string + if (typeof fieldValue !== 'string') { + fieldValue = String(fieldValue); + } + + // Special handling for text field - convert to proper RichTextField format + if (fieldName === 'text') { + try { + // Check if it's already a valid JSON RichTextField + JSON.parse(fieldValue); + return fieldValue; + } catch (e) { + // It's a plain text string, so convert it to RichTextField format + const rtf = { + doc: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: fieldValue, + }, + ], + }, + ], + }, + }; + return JSON.stringify(rtf); + } + } + + // Get field metadata + const normalizedFieldName = fieldName.startsWith('_') ? fieldName : `_${fieldName}`; + const strippedFieldName = fieldName.startsWith('_') ? fieldName.substring(1) : fieldName; + + // Check both versions of the field name in metadata + const fieldMeta = this.fieldMetadata[normalizedFieldName] || this.fieldMetadata[strippedFieldName]; + + // Special handling for width and height without metadata + if (!fieldMeta && (fieldName === '_width' || fieldName === '_height' || fieldName === 'width' || fieldName === 'height')) { + const num = Number(fieldValue); + return isNaN(num) ? fieldValue : num; + } + + if (!fieldMeta) { + // If no metadata found, just return the string value + return fieldValue; + } + + // Convert based on field type + const fieldType = fieldMeta.type; + + if (fieldType === 'boolean') { + // Convert to boolean + return fieldValue.toLowerCase() === 'true'; + } else if (fieldType === 'number') { + // Convert to number + const num = Number(fieldValue); + return isNaN(num) ? fieldValue : num; + } else if (fieldType === 'date') { + // Try to convert to date (stored as number timestamp) + try { + return new Date(fieldValue).getTime(); + } catch (e) { + return fieldValue; + } + } else if (fieldType.includes('list') || fieldType.includes('array')) { + // Try to parse as JSON array + try { + return JSON.parse(fieldValue); + } catch (e) { + return fieldValue; + } + } else if (fieldType === 'json' || fieldType === 'object') { + // Try to parse as JSON object + try { + return JSON.parse(fieldValue); + } catch (e) { + return fieldValue; + } + } + + // Default to string + return fieldValue; + } + + /** + * Extracts all field metadata from DocumentOptions + * @returns A structured object containing metadata about all available document fields + */ + public getAllFieldMetadata() { + // Start with our already populated fieldMetadata from the DocumentOptions class + const result: Record<string, any> = { + fieldCount: Object.keys(this.fieldMetadata).length, + fields: {}, + fieldsByType: { + string: [], + number: [], + boolean: [], + //doc: [], + //list: [], + //date: [], + //enumeration: [], + //other: [], + }, + fieldNameMappings: {}, + commonFields: { + appearance: [], + position: [], + size: [], + content: [], + behavior: [], + layout: [], + }, + }; + + // Process each field in the metadata + Object.entries(this.fieldMetadata).forEach(([fieldName, fieldInfo]) => { + const strippedName = fieldName.startsWith('_') ? fieldName.substring(1) : fieldName; + + // Add to fieldNameMappings + if (fieldName.startsWith('_')) { + result.fieldNameMappings[strippedName] = fieldName; + } + + // Create structured field metadata + const fieldData: Record<string, any> = { + name: fieldName, + displayName: strippedName, + description: fieldInfo.description || '', + type: fieldInfo.fieldType || 'unknown', + possibleValues: fieldInfo.values || [], + }; + + // Add field to fields collection + result.fields[fieldName] = fieldData; + + // Categorize by field type + const type = fieldInfo.fieldType?.toLowerCase() || 'unknown'; + if (type === 'string') { + result.fieldsByType.string.push(fieldName); + } else if (type === 'number') { + result.fieldsByType.number.push(fieldName); + } else if (type === 'boolean') { + result.fieldsByType.boolean.push(fieldName); + } else if (type === 'doc') { + //result.fieldsByType.doc.push(fieldName); + } else if (type === 'list') { + //result.fieldsByType.list.push(fieldName); + } else if (type === 'date') { + //result.fieldsByType.date.push(fieldName); + } else if (type === 'enumeration') { + //result.fieldsByType.enumeration.push(fieldName); + } else { + //result.fieldsByType.other.push(fieldName); + } + + // Categorize by field purpose + if (fieldName.includes('width') || fieldName.includes('height') || fieldName.includes('size')) { + result.commonFields.size.push(fieldName); + } else if (fieldName.includes('color') || fieldName.includes('background') || fieldName.includes('border')) { + result.commonFields.appearance.push(fieldName); + } else if (fieldName.includes('x') || fieldName.includes('y') || fieldName.includes('position') || fieldName.includes('pan')) { + result.commonFields.position.push(fieldName); + } else if (fieldName.includes('text') || fieldName.includes('title') || fieldName.includes('data')) { + result.commonFields.content.push(fieldName); + } else if (fieldName.includes('action') || fieldName.includes('click') || fieldName.includes('event')) { + result.commonFields.behavior.push(fieldName); + } else if (fieldName.includes('layout')) { + result.commonFields.layout.push(fieldName); + } + }); + + // Add special section for auto-sizing related fields + result.autoSizingFields = { + height: { + autoHeightField: '_layout_autoHeight', + heightField: '_height', + displayName: 'height', + usage: 'To manually set height, first set layout_autoHeight to false', + }, + width: { + autoWidthField: '_layout_autoWidth', + widthField: '_width', + displayName: 'width', + usage: 'To manually set width, first set layout_autoWidth to false', + }, + }; + + // Add special section for text field format + result.specialFields = { + text: { + name: 'text', + description: 'Document text content', + format: 'RichTextField', + note: 'When setting text, provide plain text - it will be automatically converted to the correct format', + example: 'For setting: "Hello world" (plain text); For getting: Will be converted to plaintext for display', + }, + }; + + return result; + } + + /** + * Edits a specific field on a document + * @param docId The ID of the document to edit + * @param fieldName The name of the field to edit + * @param fieldValue The new value for the field (string, number, or boolean) + * @returns Object with success status, message, and additional information + */ + public editDocumentField( + docId: string, + fieldName: string, + fieldValue: string | number | boolean + ): { + success: boolean; + message: string; + fieldName?: string; + originalFieldName?: string; + newValue?: any; + warning?: string; + } { + // Normalize field name (handle with/without underscore) + let normalizedFieldName = fieldName.startsWith('_') ? fieldName : fieldName; + const strippedFieldName = fieldName.startsWith('_') ? fieldName.substring(1) : fieldName; + + // Handle common field name aliases (width → _width, height → _height) + // Many document fields use '_' prefix for layout properties + if (fieldName === 'width') { + normalizedFieldName = '_width'; + } else if (fieldName === 'height') { + normalizedFieldName = '_height'; + } + + // Get the documents + const doc = this.documentsById.get(docId); + if (!doc) { + return { success: false, message: `Document with ID ${docId} not found` }; + } + + const { layoutDoc, dataDoc } = this.documentsById.get(docId) ?? { layoutDoc: null, dataDoc: null }; + + if (!layoutDoc && !dataDoc) { + return { success: false, message: `Could not find layout or data document for document with ID ${docId}` }; + } + + try { + // Convert the field value to the appropriate type based on field metadata + const convertedValue = this.convertFieldValue(normalizedFieldName, fieldValue); + + let targetDoc: Doc | undefined; + let targetLocation: string; + + // First, check if field exists on layout document using Doc.Get + if (layoutDoc) { + const fieldExistsOnLayout = Doc.Get(layoutDoc, normalizedFieldName, true) !== undefined; + + // If it exists on layout document, update it there + if (fieldExistsOnLayout) { + targetDoc = layoutDoc; + targetLocation = 'layout'; + } + // If it has an underscore prefix, it's likely a layout property even if not yet set + else if (normalizedFieldName.startsWith('_')) { + targetDoc = layoutDoc; + targetLocation = 'layout'; + } + // Otherwise, look for or create on data document + else if (dataDoc) { + targetDoc = dataDoc; + targetLocation = 'data'; + } + // If no data document available, default to layout + else { + targetDoc = layoutDoc; + targetLocation = 'layout'; + } + } + // If no layout document, use data document + else if (dataDoc) { + targetDoc = dataDoc; + targetLocation = 'data'; + } else { + return { success: false, message: `No valid document found for editing` }; + } + + if (!targetDoc) { + return { success: false, message: `Target document not available` }; + } + + // Set the field value on the target document + targetDoc[normalizedFieldName] = convertedValue; + + return { + success: true, + message: `Successfully updated field '${normalizedFieldName}' on ${targetLocation} document (ID: ${docId})`, + fieldName: normalizedFieldName, + originalFieldName: fieldName, + newValue: convertedValue, + }; + } catch (error) { + console.error('Error editing document field:', error); + return { + success: false, + message: `Error updating field: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + /** + * Gets metadata for a specific document or all documents + * @param documentId Optional ID of a specific document to get metadata for + * @returns Document metadata or metadata for all documents + */ + public getDocumentMetadata(documentId?: string): any { + if (documentId) { + const doc = this.documentsById.get(documentId); + // Get metadata for a specific document + return this.extractDocumentMetadata(doc); + } else { + // Get metadata for all documents + const documentsMetadata: Record<string, any> = {}; + for (const doc of this.documentsById.values()) { + documentsMetadata.add(this.extractDocumentMetadata(doc)); + } + + return { + documentCount: this.documentsById.size, + documents: documentsMetadata, + fieldDefinitions: this.fieldMetadata, + }; + } + } + + /** + * Adds links between documents based on their IDs + * @param docIds Array of document IDs to link + * @param relationship Optional relationship type for the links + * @returns Array of created link documents + */ + public addLinks(docIds: string[]): Doc[] { + const createdLinks: Doc[] = []; + // Use string keys for Set instead of arrays which don't work as expected as keys + const alreadyLinked = new Set<string>(); + + // Iterate over the document IDs and add links + docIds.forEach(docId1 => { + const doc1 = this.documentsById.get(docId1); + docIds.forEach(docId2 => { + if (docId1 === docId2) return; // Skip self-linking + + // Create a consistent key regardless of document order + const linkKey = [docId1, docId2].sort().join('_'); + if (alreadyLinked.has(linkKey)) return; + + const doc2 = this.documentsById.get(docId2); + if (doc1?.layoutDoc && doc2?.layoutDoc) { + try { + // Create a link document between doc1 and doc2 + const linkDoc = Docs.Create.LinkDocument(doc1.layoutDoc, doc2.layoutDoc); + + // Set a default color if relationship doesn't specify one + if (!linkDoc.color) { + linkDoc.color = 'lightBlue'; // Default blue color + } + + // Ensure link is visible by setting essential properties + linkDoc.link_visible = true; + linkDoc.link_enabled = true; + linkDoc.link_autoMove = true; + linkDoc.link_showDirected = true; + + // Set the embedContainer to ensure visibility + // This is shown in the image as a key difference between visible/non-visible links + if (this.chatBoxDocument && this.chatBoxDocument.parent && typeof this.chatBoxDocument.parent === 'object' && 'title' in this.chatBoxDocument.parent) { + linkDoc.embedContainer = String(this.chatBoxDocument.parent.title); + } else if (doc1.layoutDoc.parent && typeof doc1.layoutDoc.parent === 'object' && 'title' in doc1.layoutDoc.parent) { + linkDoc.embedContainer = String(doc1.layoutDoc.parent.title); + } else { + // Default to a tab name if we can't find one + linkDoc.embedContainer = 'Untitled Tab 1'; + } + + // Add the link to the document system + LinkManager.Instance.addLink(linkDoc); + + const ancestor = DocumentView.linkCommonAncestor(linkDoc); + ancestor?.ComponentView?.addDocument?.(linkDoc); + // Add to user document list to make it visible in the UI + Doc.AddDocToList(Doc.UserDoc(), 'links', linkDoc); + + // Create a visual link for display + if (this.chatBoxDocument) { + // Make sure the docs are visible in the UI + this.chatBox._props.addDocument?.(doc1.layoutDoc); + this.chatBox._props.addDocument?.(doc2.layoutDoc); + + // Use DocumentManager to ensure documents are visible + DocumentManager.Instance.showDocument(doc1.layoutDoc, { willZoomCentered: false }); + DocumentManager.Instance.showDocument(doc2.layoutDoc, { willZoomCentered: false }); + } + + createdLinks.push(linkDoc); + alreadyLinked.add(linkKey); + } catch (error) { + console.error('Error creating link between documents:', error); + } + } + }); + }); + + // Force update of the UI to show new links + setTimeout(() => { + try { + // Update server cache to ensure links are persisted + UPDATE_SERVER_CACHE && typeof UPDATE_SERVER_CACHE === 'function' && UPDATE_SERVER_CACHE(); + } catch (e) { + console.warn('Could not update server cache after creating links:', e); + } + }, 100); + + return createdLinks; + } + /** + * Helper method to validate a document type and ensure it's a valid supportedDocType + * @param docType The document type to validate + * @returns True if the document type is valid, false otherwise + */ + private isValidDocType(docType: string): boolean { + return Object.values(supportedDocTypes).includes(docType as supportedDocTypes); + } + /** + * 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. + */ + createDocInDash = (docType: string, title: string, data: string) => { + // Validate doc_type + if (!this.isValidDocType(docType)) { + throw new Error(`Invalid document type: ${docType}`); + } + + try { + // Create simple document with just title and data + const simpleDoc: parsedDoc = { + doc_type: docType, + title: title, + data: data, + x: 0, + y: 0, + _width: 300, + _height: 300, + _layout_fitWidth: false, + _layout_autoHeight: true, + }; + + // Use the chatBox's createDocInDash method to create and link the document + if (!this.chatBox) { + throw new Error('ChatBox instance not available for creating document'); + } + const linkAndShowDoc = (doc: Opt<Doc>) => { + if (doc) { + LinkManager.Instance.addLink(Docs.Create.LinkDocument(this.chatBoxDocument!, doc)); + this.chatBox._props.addDocument?.(doc); + DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, () => {}); + } + }; + const doc = this.chatBox.whichDoc(simpleDoc, false); + if (doc) linkAndShowDoc(doc); + return doc; + } catch (error) { + throw new Error(`Error creating document: ${error}`); + } + }; + + public has(docId: string) { + return this.documentsById.has(docId); + } + + public listDocs() { + // List all available documents in simple format + const docs = Array.from(this.documentsById.entries()).map(([id, doc]) => ({ + id, + title: doc.layoutDoc.title || 'Untitled Document', + type: doc.layoutDoc.type || doc.dataDoc.type || 'Unknown Type', + })); + + if (docs.length === 0) { + return [ + { + type: 'text', + text: 'No documents found in the current view.', + }, + ]; + } + + return [ + { + type: 'text', + text: `Found ${docs.length} document(s) in the current view:\n${JSON.stringify(docs, null, 2)}`, + }, + ]; + } + + public createAgentDoc(doc: Doc) { + // Ideally check if Doc is already in there. + const agentDoc = { layoutDoc: doc, dataDoc: doc[DocData] }; + this.documentsById.set(this.ensureDocumentId(doc), agentDoc); + return agentDoc; + } +} |