From c079b7aa916d7eb35ce186806ca312c84e910cb2 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Fri, 14 Feb 2025 14:07:18 -0500
Subject: major restructure of GPTPopup so that it works with all collections.

---
 src/client/views/pdf/GPTPopup/GPTPopup.tsx | 231 +++++++++++++++++++----------
 1 file changed, 150 insertions(+), 81 deletions(-)

(limited to 'src/client/views/pdf/GPTPopup/GPTPopup.tsx')

diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
index 0b1ee78e3..f09d786d0 100644
--- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx
+++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
@@ -1,6 +1,6 @@
 import { Button, IconButton, Toggle, ToggleType, Type } from '@dash/components';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { action, makeObservable, observable } from 'mobx';
+import { action, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
 import { CgClose, CgCornerUpLeft } from 'react-icons/cg';
@@ -10,12 +10,15 @@ import { ClientUtils } from '../../../../ClientUtils';
 import { Doc } from '../../../../fields/Doc';
 import { NumCast, StrCast } from '../../../../fields/Types';
 import { Networking } from '../../../Network';
-import { GPTCallType, GPTTypeStyle, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT';
+import { DescriptionSeperator, DocSeperator, GPTCallType, GPTTypeStyle, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT';
 import { DocUtils } from '../../../documents/DocUtils';
 import { Docs } from '../../../documents/Documents';
 import { SettingsManager } from '../../../util/SettingsManager';
 import { SnappingManager } from '../../../util/SnappingManager';
+import { undoable } from '../../../util/UndoManager';
 import { ObservableReactComponent } from '../../ObservableReactComponent';
+import { TagItem } from '../../TagsView';
+import { docSortings } from '../../collections/CollectionSubView';
 import { DocumentView } from '../../nodes/DocumentView';
 import { AnchorMenu } from '../AnchorMenu';
 import './GPTPopup.scss';
@@ -29,61 +32,71 @@ export enum GPTPopupMode {
     QUIZ_RESPONSE, // user definitions or explanations to be evaluated by GPT
 }
 
-export enum GPTQuizType {
-    CURRENT = 0,
-    CHOOSE = 1,
-    MULTIPLE = 2,
-}
-
 @observer
 export class GPTPopup extends ObservableReactComponent<object> {
     // eslint-disable-next-line no-use-before-define
     static Instance: GPTPopup;
-    private _retrieveDocDescriptions: (() => Promise<void>) | null = null;
     private _messagesEndRef: React.RefObject<HTMLDivElement>;
     private _correlatedColumns: string[] = [];
     private _dataChatPrompt: string | undefined = undefined;
     private _imgTargetDoc: Doc | undefined;
     private _textAnchor: Doc | undefined;
     private _dataJson: string = '';
-    private _documentDescriptions: string = '';
+    private _documentDescriptions: Promise<string> | undefined; // a cache of the descriptions of all docs in the selected collection. makes it more efficient when asking GPT multiple questions about the collection.
     private _sidebarFieldKey: string = '';
     private _textToSummarize: string = '';
     private _imageDescription: string = '';
+    private _textToDocMap = new Map<string, Doc>(); // when GPT answers with a doc's content, this helps us find the Doc
+    private _addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined;
+
+    constructor(props: object) {
+        super(props);
+        makeObservable(this);
+        GPTPopup.Instance = this;
+        this._messagesEndRef = React.createRef();
+    }
 
+    public addDoc: ((doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean) | undefined;
+    public createFilteredDoc: (axes?: string[]) => boolean = () => false;
     public setSidebarFieldKey = (id: string) => (this._sidebarFieldKey = id);
-    public setDocumentDescriptions = (t: string) => (this._documentDescriptions = t);
     public setImgTargetDoc = (anchor: Doc) => (this._imgTargetDoc = anchor);
     public setTextAnchor = (anchor: Doc) => (this._textAnchor = anchor);
-    public onGptResponse?: (sortResult: string, questionType: GPTTypeStyle, tag?: string) => void;
-    public onQuizRandom?: () => void;
     public setDataJson = (text: string) => {
         if (text === '') this._dataChatPrompt = '';
         this._dataJson = text;
     };
 
-    constructor(props: object) {
-        super(props);
-        makeObservable(this);
-        GPTPopup.Instance = this;
-        this._messagesEndRef = React.createRef();
+    componentDidUpdate() {
+        this._gptProcessing && this.setStopAnimatingResponse(false);
+    }
+    componentDidMount(): void {
+        reaction(
+            () => DocumentView.Selected().lastElement(),
+            selDoc => {
+                const hasChildDocs = selDoc?.ComponentView?.hasChildDocs;
+                if (hasChildDocs) {
+                    this._textToDocMap.clear();
+                    this.setCollectionContext(selDoc.Document);
+                    this.onGptResponse = (sortResult: string, questionType: GPTTypeStyle, tag?: string) => this.processGptResponse(selDoc, this._textToDocMap, sortResult, questionType, tag);
+                    this.onQuizRandom = () => this.randomlyChooseDoc(selDoc.Document, hasChildDocs());
+                    this._documentDescriptions = Promise.all(hasChildDocs().map(doc =>
+                            Doc.getDescription(doc).then(text => this._textToDocMap.set(text, doc) && `${DescriptionSeperator}${text}${DescriptionSeperator}`)
+                        )).then(docDescriptions => docDescriptions.join()); // prettier-ignore
+                }
+            },
+            { fireImmediately: true }
+        );
     }
-
-    componentDidUpdate = () => this._gptProcessing && this.setStopAnimatingResponse(false);
 
     @observable private _conversationArray: string[] = ['Hi! In this pop up, you can ask ChatGPT questions about your documents and filter / sort them. '];
     @observable private _chatEnabled: boolean = false;
     @action private setChatEnabled = (start: boolean) => (this._chatEnabled = start);
-    @observable public Visible: boolean = false;
-    @action public setVisible = (vis: boolean) => (this.Visible = vis);
     @observable private _gptProcessing: boolean = false;
-    @action public setGptProcessing = (loading: boolean) => (this._gptProcessing = loading);
+    @action private setGptProcessing = (loading: boolean) => (this._gptProcessing = loading);
     @observable private _responseText: string = '';
-    @action public setResponseText = (text: string) => (this._responseText = text);
+    @action private setResponseText = (text: string) => (this._responseText = text);
     @observable private _imgUrls: string[][] = [];
-    @action public setImgUrls = (imgs: string[][]) => (this._imgUrls = imgs);
-    @observable private _mode: GPTPopupMode = GPTPopupMode.SUMMARY;
-    @action public setMode = (mode: GPTPopupMode) => (this._mode = mode);
+    @action private setImgUrls = (imgs: string[][]) => (this._imgUrls = imgs);
     @observable private _collectionContext: Doc | undefined = undefined;
     @action setCollectionContext = (doc: Doc | undefined) => (this._collectionContext = doc);
     @observable private _userPrompt: string = '';
@@ -91,23 +104,68 @@ export class GPTPopup extends ObservableReactComponent<object> {
     @observable private _quizAnswer: string = '';
     @action setQuizAnswer = (e: React.ChangeEvent<HTMLInputElement>) => (this._quizAnswer = e.target.value);
     @observable private _stopAnimatingResponse: boolean = false;
-    @action public setStopAnimatingResponse = (done: boolean) => (this._stopAnimatingResponse = done);
+    @action private setStopAnimatingResponse = (done: boolean) => (this._stopAnimatingResponse = done);
+
+    @observable private _mode: GPTPopupMode = GPTPopupMode.SUMMARY;
+    @action public setMode = (mode: GPTPopupMode) => (this._mode = mode);
+    @observable public Visible: boolean = false;
+    @action public setVisible = (vis: boolean) => (this.Visible = vis);
+
+    onQuizRandom?: () => void;
+    onGptResponse?: (sortResult: string, questionType: GPTTypeStyle, tag?: string) => void;
+    questionTypeNumberToStyle = (questionType: string) => +questionType.split(' ')[0][0];
 
     /**
-     * sets callback needed to retrieve an updated description of the collection's documents
-     * @param collectionDoc - the collection doc context for the GPT prompts
-     * @param callback - function to retrieve document descriptions
+     * Processes gpt's output depending on the type of question the user asked. Converts gpt's string output to
+     * usable code
+     * @param gptOutput
+     * @param questionType
+     * @param tag
      */
-    public setRetrieveDocDescriptionsCallback(collectionDoc: Doc | undefined, callback: null | (() => Promise<void>)) {
-        this.setCollectionContext(collectionDoc);
-        this._retrieveDocDescriptions = callback;
-    }
+    processGptResponse = (docView: DocumentView, textToDocMap: Map<string, Doc>, gptOutput: string, questionType: GPTTypeStyle, tag?: string) =>
+        undoable(() => {
+            // Split the string into individual list items
+            const listItems = gptOutput.split('======').filter(item => item.trim() !== '');
 
-    public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false;
-    public createFilteredDoc: (axes?: string[]) => boolean = () => false;
-    public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined;
-    public questionTypeNumberToStyle = (questionType: string) => +questionType.split(' ')[0][0];
+            if (questionType === GPTTypeStyle.Filter) {
+                docView.ComponentView?.hasChildDocs?.().forEach(d => TagItem.removeTagFromDoc(d, '#chat'));
+            }
+
+            if (questionType === GPTTypeStyle.SortDocs) {
+                docView.Document[docView.ComponentView?.fieldKey + '_sort'] = docSortings.Chat;
+            }
 
+            listItems.forEach((item, index) => {
+                const normalizedItem = item.replace(/\n/g, ' ').trim();
+                // find the corresponding Doc in the textToDoc map
+                const doc = textToDocMap.get(normalizedItem);
+                if (doc) {
+                    switch (questionType) {
+                        case GPTTypeStyle.SortDocs:
+                            doc.chatIndex = index;
+                            break;
+                        case GPTTypeStyle.AssignTags:
+                            if (tag) {
+                                const hashTag = tag.startsWith('#') ? tag : '#' + tag[0].toLowerCase() + tag.slice(1);
+                                const filterTag = Doc.MyFilterHotKeys.map(key => StrCast(key.toolType)).find(key => key.includes(tag)) ?? hashTag;
+                                TagItem.addTagToDoc(doc, filterTag);
+                            }
+                            break;
+                        case GPTTypeStyle.Filter:
+                            TagItem.addTagToDoc(doc, '#chat');
+                            Doc.setDocFilter(docView.Document, 'tags', '#chat', 'check');
+                            break;
+                    }
+                } else {
+                    console.warn(`No matching document found for item: ${normalizedItem}`);
+                }
+            });
+        }, '')();
+
+    /**
+     * When in quiz mode, randomly selects a document
+     */
+    randomlyChooseDoc = (doc: Doc, childDocs: Doc[]) => DocumentView.getDocumentView(childDocs[Math.floor(Math.random() * childDocs.length)])?.select(false);
     /**
      * Generates a rubric for evaluating the user's description of the document's text
      * @param doc the doc the user is providing info about
@@ -116,9 +174,11 @@ export class GPTPopup extends ObservableReactComponent<object> {
     generateRubric = (doc: Doc) =>
         StrCast(doc.gptRubric)
             ? Promise.resolve(StrCast(doc.gptRubric))
-            : gptAPICall(StrCast(doc.gptInputText), GPTCallType.RUBRIC)
-                  .then(res => (doc.gptRubric = res))
-                  .catch(err => console.error('GPT call failed', err));
+            : Doc.getDescription(doc).then(desc =>
+                  gptAPICall(desc, GPTCallType.RUBRIC)
+                      .then(res => (doc.gptRubric = res))
+                      .catch(err => console.error('GPT call failed', err))
+              );
 
     /**
      * When the cards are in quiz mode in the card view, allows gpt to determine whether the user's answer was correct
@@ -128,17 +188,18 @@ export class GPTPopup extends ObservableReactComponent<object> {
      */
     generateQuizAnswerAnalysis = (doc: Doc, quizAnswer: string) =>
         this.generateRubric(doc).then(() =>
-            gptAPICall(
-                `Question: ${StrCast(doc.gptInputText)}; 
-                 UserAnswer: ${quizAnswer};
-                 Rubric: ${StrCast(doc.gptRubric)}`,
-                GPTCallType.QUIZ
-            ).then(res => {
-                this._conversationArray.push(res || 'GPT provided no answer');
-                this.onQuizRandom?.();
-            })
-            .catch(err => console.error('GPT call failed', err))
-        ) // prettier-ignore
+            Doc.getDescription(doc).then(desc => 
+                gptAPICall(
+                    `Question: ${desc}; 
+                    UserAnswer: ${quizAnswer};
+                    Rubric: ${StrCast(doc.gptRubric)}`,
+                    GPTCallType.QUIZ
+                ).then(res => {
+                    this._conversationArray.push(res || 'GPT provided no answer');
+                    this.onQuizRandom?.();
+                })
+                .catch(err => console.error('GPT call failed', err))
+        )) // prettier-ignore
 
     /**
      * Generates a response to the user's question about the docs in the collection.
@@ -146,32 +207,33 @@ export class GPTPopup extends ObservableReactComponent<object> {
      * @param userPrompt the user's input that chat will respond to
      */
     generateUserPromptResponse = (userPrompt: string) =>
-        (this._retrieveDocDescriptions ?? Promise.resolve)().then(() =>
-            gptAPICall(userPrompt, GPTCallType.TYPE).then(questionType =>
-                (() => {
-                    switch (this.questionTypeNumberToStyle(questionType)) {
-                        case GPTTypeStyle.AssignTags:
-                        case GPTTypeStyle.Filter:    return gptAPICall(this._documentDescriptions, GPTCallType.SUBSET, userPrompt);
-                        case GPTTypeStyle.SortDocs:  return gptAPICall(this._documentDescriptions, GPTCallType.SORT, userPrompt);
-                        default:                     return gptAPICall(StrCast(DocumentView.SelectedDocs().lastElement()?.gptInputText), GPTCallType.INFO, userPrompt);
-                    } // prettier-ignore
-                })().then(
-                    action(res => {
-                        // Trigger the callback with the result
-                        this.onGptResponse?.(res || 'Something went wrong :(', this.questionTypeNumberToStyle(questionType), questionType.split(' ').slice(1).join(' '));
-                        this._conversationArray.push(
-                            ![GPTTypeStyle.GeneralInfo, GPTTypeStyle.DocInfo].includes(this.questionTypeNumberToStyle(questionType))? 
-                            // Extract explanation surrounded by ------ at the top or both at the top and bottom
-                            (res.match(/------\s*([\s\S]*?)\s*(?:------|$)/) ?? [])[1]?.trim() ?? 'No explanation found' : res);
-                    })
-                ).catch(err => console.log(err))
+        gptAPICall(userPrompt, GPTCallType.TYPE).then(questionType =>
+            (async () => {
+                switch (this.questionTypeNumberToStyle(questionType)) {
+                    case GPTTypeStyle.AssignTags:
+                    case GPTTypeStyle.Filter:    return this._documentDescriptions?.then(descs => gptAPICall(descs, GPTCallType.SUBSET, userPrompt)) ?? "";
+                    case GPTTypeStyle.SortDocs:  return this._documentDescriptions?.then(descs => gptAPICall(descs, GPTCallType.SORT, userPrompt)) ?? "";
+                    default:                     return Doc.getDescription(DocumentView.SelectedDocs().lastElement()).then(desc => gptAPICall(desc, GPTCallType.INFO, userPrompt));
+                } // prettier-ignore
+            })().then(
+                action(res => {
+                    // Trigger the callback with the result
+                    this.onGptResponse?.(res || 'Something went wrong :(', this.questionTypeNumberToStyle(questionType), questionType.split(' ').slice(1).join(' '));
+                    this._conversationArray.push(
+                        [GPTTypeStyle.GeneralInfo, GPTTypeStyle.DocInfo].includes(this.questionTypeNumberToStyle(questionType)) ? res:
+                        // Extract explanation surrounded by the DocSeperator string (defined in GPT.ts) at the top or both at the top and bottom
+                        (res.match(new RegExp(`${DocSeperator}\\s*([\\s\\S]*?)\\s*(?:${DocSeperator}|$)`)) ?? [])[1]?.trim() ?? 'No explanation found'
+                    );
+                })
             ).catch(err => console.log(err))
-        ); // prettier-ignore
+        ).catch(err => console.log(err)); // prettier-ignore
 
     /**
      * Generates a Dalle image and uploads it to the server.
      */
-    generateImage = (imgDesc: string) => {
+    generateImage = (imgDesc: string, imgTarget: Doc, addToCollection?: (doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) => {
+        this._imgTargetDoc = imgTarget;
+        this.addDoc = addToCollection;
         this.setImgUrls([]);
         this.setMode(GPTPopupMode.IMAGE);
         this.setVisible(true);
@@ -237,7 +299,7 @@ export class GPTPopup extends ObservableReactComponent<object> {
             _layout_fitWidth: true,
             _layout_autoHeight: true,
         });
-        this.addDoc(newDoc, this._sidebarFieldKey);
+        this.addDoc?.(newDoc, this._sidebarFieldKey);
         const anchor = AnchorMenu.Instance?.GetAnchor(undefined, false);
         if (anchor) {
             DocUtils.MakeLink(newDoc, anchor, {
@@ -270,7 +332,7 @@ export class GPTPopup extends ObservableReactComponent<object> {
                 newDoc.overlayY = NumCast(textAnchor.y) + NumCast(textAnchor._height);
                 Doc.AddToMyOverlay(newDoc);
             } else {
-                this.addToCollection?.(newDoc);
+                this.addDoc?.(newDoc);
             }
             // Create link between prompt and image
             DocUtils.MakeLink(textAnchor, newDoc, { link_relationship: 'Image Prompt' });
@@ -282,8 +344,8 @@ export class GPTPopup extends ObservableReactComponent<object> {
     gptMenu = () => (
         <div className="btns-wrapper-gpt">
             <Button
-                tooltip="Have ChatGPT sort, tag, define, or filter your documents for you!"
-                text="Modify/Sort Cards!"
+                tooltip="Ask GPT to sort, tag, define, or filter your Docs!"
+                text="Ask GPT"
                 onClick={() => this.setMode(GPTPopupMode.USER_PROMPT)}
                 color={StrCast(Doc.UserDoc().userVariantColor)}
                 type={Type.TERT}
@@ -297,8 +359,8 @@ export class GPTPopup extends ObservableReactComponent<object> {
                 }}
             />
             <Button
-                tooltip="Test your knowledge with ChatGPT!"
-                text="Quiz Cards!"
+                tooltip="Test your knowledge by verifying answers with ChatGPT"
+                text="Take Quiz"
                 onClick={() => {
                     this._conversationArray = ['Define the selected card!'];
                     this.setMode(GPTPopupMode.QUIZ_RESPONSE);
@@ -386,7 +448,14 @@ export class GPTPopup extends ObservableReactComponent<object> {
                     </div>
                 ))}
             </div>
-            {this._gptProcessing ? null : <IconButton tooltip="Generate Again" onClick={() => this.generateImage(this._imageDescription)} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(Doc.UserDoc().userVariantColor)} />}
+            {this._gptProcessing ? null : (
+                <IconButton
+                    tooltip="Generate Again"
+                    onClick={() => this._imgTargetDoc && this.generateImage(this._imageDescription, this._imgTargetDoc, this._addToCollection)}
+                    icon={<FontAwesomeIcon icon="redo-alt" size="lg" />}
+                    color={StrCast(Doc.UserDoc().userVariantColor)}
+                />
+            )}
         </div>
     );
 
-- 
cgit v1.2.3-70-g09d2

