diff options
Diffstat (limited to 'src/client/views/pdf')
| -rw-r--r-- | src/client/views/pdf/GPTPopup/GPTPopup.tsx | 231 |
1 files changed, 150 insertions, 81 deletions
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> ); |
