diff options
| author | bobzel <zzzman@gmail.com> | 2025-03-16 19:02:56 -0400 |
|---|---|---|
| committer | bobzel <zzzman@gmail.com> | 2025-03-16 19:02:56 -0400 |
| commit | df708c90d8356934d2e3d9123129c761d328c1fe (patch) | |
| tree | 98b0588710ac8ca00c303960da0851614aacf597 /src/client/views/pdf | |
| parent | 7d9fae09e8906e5636f6ea695ad560797b08d023 (diff) | |
| parent | f4042257be7359734a0dd35cedbf03fe4aa14cf1 (diff) | |
Merge branch 'DocCreatorMenu-work' of https://github.com/brown-dash/Dash-Web into DocCreatorMenu-work
Diffstat (limited to 'src/client/views/pdf')
| -rw-r--r-- | src/client/views/pdf/AnchorMenu.tsx | 136 | ||||
| -rw-r--r-- | src/client/views/pdf/Annotation.scss | 2 | ||||
| -rw-r--r-- | src/client/views/pdf/GPTPopup/GPTPopup.scss | 111 | ||||
| -rw-r--r-- | src/client/views/pdf/GPTPopup/GPTPopup.tsx | 989 | ||||
| -rw-r--r-- | src/client/views/pdf/PDFViewer.scss | 35 | ||||
| -rw-r--r-- | src/client/views/pdf/PDFViewer.tsx | 101 |
6 files changed, 616 insertions, 758 deletions
diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 1a79bbbfe..eaaeb8d97 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -1,24 +1,19 @@ +import { ColorPicker, Group, IconButton, Popup, Size, Toggle, ToggleType, Type } from '@dash/components'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { ColorPicker, Group, IconButton, Popup, Size, Toggle, ToggleType, Type } from 'browndash-components'; -import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { ColorResult } from 'react-color'; -import ReactLoading from 'react-loading'; import { ClientUtils, returnFalse, setupMoveUpEvents } from '../../../ClientUtils'; import { emptyFunction, unimplementedFunction } from '../../../Utils'; import { Doc, Opt } from '../../../fields/Doc'; -import { DocData } from '../../../fields/DocSymbols'; -import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT'; -import { Docs } from '../../documents/Documents'; import { SettingsManager } from '../../util/SettingsManager'; -import { undoBatch } from '../../util/UndoManager'; import { AntimodeMenu, AntimodeMenuProps } from '../AntimodeMenu'; import { LinkPopup } from '../linking/LinkPopup'; +import { ComparisonBox } from '../nodes/ComparisonBox'; import { DocumentView } from '../nodes/DocumentView'; -import { DrawingOptions, SmartDrawHandler } from '../smartdraw/SmartDrawHandler'; import './AnchorMenu.scss'; -import { GPTPopup, GPTPopupMode } from './GPTPopup/GPTPopup'; +import { GPTPopup } from './GPTPopup/GPTPopup'; @observer export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { @@ -28,6 +23,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { private _disposer: IReactionDisposer | undefined; private _commentRef = React.createRef<HTMLDivElement>(); private _cropRef = React.createRef<HTMLDivElement>(); + @observable private _loading = false; constructor(props: AntimodeMenuProps) { super(props); @@ -42,12 +38,22 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { // GPT additions @observable private _selectedText: string = ''; + @observable private _x: number = 0; + @observable private _y: number = 0; @observable private _isLoading: boolean = false; @action public setSelectedText = (txt: string) => { this._selectedText = txt.trim(); }; + @action + public setLocation = (x: number, y: number) => { + this._x = x; + this._y = y; + }; + @computed public get selectedText() { + return this._selectedText; + } public onMakeAnchor: () => Opt<Doc> = () => undefined; // Method to get anchor from text search public OnCrop: (e: PointerEvent) => void = unimplementedFunction; @@ -62,6 +68,10 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { public MakeTargetToggle: () => void = unimplementedFunction; public ShowTargetTrail: () => void = unimplementedFunction; public IsTargetToggler: () => boolean = returnFalse; + public gptFlashcards: () => void = unimplementedFunction; + public makeLabels: () => void = unimplementedFunction; + public marqueeWidth = 0; + public marqueeHeight = 0; public get Active() { return this._left > 0; } @@ -83,94 +93,24 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { * Invokes the API with the selected text and stores it in the summarized text. * @param e pointer down event */ - gptSummarize = async () => { - GPTPopup.Instance.setVisible(true); - GPTPopup.Instance.setMode(GPTPopupMode.SUMMARY); - GPTPopup.Instance.setLoading(true); - - try { - const res = await gptAPICall(this._selectedText, GPTCallType.SUMMARY); - GPTPopup.Instance.setText(res || 'Something went wrong.'); - } catch (err) { - console.error(err); - } - GPTPopup.Instance.setLoading(false); - }; - // gptSummarize = async () => { - // GPTPopup.Instance?.setSelectedText(this._selectedText); - // GPTPopup.Instance.generateSummary(); - // }; - - /** - * Invokes the API with the selected text and stores it in the selected text. - * @param e pointer down event - */ - gptFlashcards = async () => { - const queryText = this._selectedText; - try { - const res = await gptAPICall(queryText, GPTCallType.FLASHCARD); - console.log(res); - GPTPopup.Instance.setText(res || 'Something went wrong.'); - this.transferToFlashcard(res || 'Something went wrong'); - } catch (err) { - console.error(err); - } - GPTPopup.Instance.setLoading(false); - }; + gptSummarize = () => GPTPopup.Instance.generateSummary(this._selectedText); /* * Transfers the flashcard text generated by GPT on flashcards and creates a collection out them. */ - transferToFlashcard = (text: string) => { - // put each question generated by GPT on the front of the flashcard - const senArr = text.split('Question'); - const collectionArr: Doc[] = []; - for (let i = 1; i < senArr.length; i++) { - console.log('Arr ' + i + ': ' + senArr[i]); - const newDoc = Docs.Create.ComparisonDocument(senArr[i], { _layout_isFlashcard: true, _width: 300, _height: 300 }); - newDoc.text = senArr[i]; - collectionArr.push(newDoc); - } - // create a new carousel collection of these flashcards - const newCol = Docs.Create.CarouselDocument(collectionArr, { - _width: 250, - _height: 200, - _layout_fitWidth: false, - _layout_autoHeight: true, - }); - this.addToCollection?.(newCol); - }; - - /** - * Creates a GPT drawing based on selected text. - */ - gptDraw = async (e: React.PointerEvent) => { - try { - SmartDrawHandler.Instance.AddDrawing = this.createDrawingAnnotation; - runInAction(() => (this._isLoading = true)); - await SmartDrawHandler.Instance.drawWithGPT({ X: e.clientX, Y: e.clientY }, this._selectedText, 5, 100, true); - runInAction(() => (this._isLoading = false)); - } catch (err) { - console.error(err); - } + transferToFlashcard = (text: string, x: number, y: number) => { + ComparisonBox.createFlashcardDeck(text, 250, 200, 'data_front', 'data_back').then( + action(newCol => { + newCol.x = x; + newCol.y = y; + newCol.zIndex = 1000; + this.addToCollection?.(newCol); + this._loading = false; + }) + ); }; - /** - * Defines how a GPT drawing should be added to the current document. - */ - @undoBatch - createDrawingAnnotation = action((drawing: Doc, opts: DrawingOptions, gptRes: string) => { - this.AddDrawingAnnotation(drawing); - const docData = drawing[DocData]; - docData.title = opts.text.match(/^(.*?)~~~.*$/)?.[1] || opts.text; - docData.drawingInput = opts.text; - docData.drawingComplexity = opts.complexity; - docData.drawingColored = opts.autoColor; - docData.drawingSize = opts.size; - docData.drawingData = gptRes; - }); - pointerDown = (e: React.PointerEvent) => { setupMoveUpEvents( this, @@ -254,20 +194,8 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { /> )} {/* Adds a create flashcards option to the anchor menu, which calls the gptFlashcard method. */} - <IconButton - tooltip="Create flashcards" // - onPointerDown={this.gptFlashcards} - icon={<FontAwesomeIcon icon="id-card" size="lg" />} - color={SettingsManager.userColor} - /> - {this._selectedText && ( - <IconButton - tooltip="Create drawing" - onPointerDown={e => this.gptDraw(e)} - icon={this._isLoading ? <ReactLoading type="spin" color={SettingsManager.userVariantColor} width={16} height={20} /> : <FontAwesomeIcon icon="paintbrush" size="lg" />} - color={SettingsManager.userColor} - /> - )} + {this.gptFlashcards === unimplementedFunction ? null : <IconButton tooltip="Create flashcards" onPointerDown={this.gptFlashcards} icon={<FontAwesomeIcon icon="layer-group" size="lg" />} color={SettingsManager.userColor} />} + {this.makeLabels === unimplementedFunction ? null : <IconButton tooltip="Create labels" onPointerDown={this.makeLabels} icon={<FontAwesomeIcon icon="tag" size="lg" />} color={SettingsManager.userColor} />} {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : ( <IconButton tooltip="Click to Record Annotation" // diff --git a/src/client/views/pdf/Annotation.scss b/src/client/views/pdf/Annotation.scss index 1de60ffed..da7efe3da 100644 --- a/src/client/views/pdf/Annotation.scss +++ b/src/client/views/pdf/Annotation.scss @@ -7,4 +7,4 @@ &:hover { cursor: pointer; } -}
\ No newline at end of file +} diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.scss b/src/client/views/pdf/GPTPopup/GPTPopup.scss index 0247dc10c..c8903e09f 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.scss +++ b/src/client/views/pdf/GPTPopup/GPTPopup.scss @@ -4,19 +4,23 @@ $greyborder: #d3d3d3; $lightgrey: #ececec; $button: #5b97ff; $highlightedText: #82e0ff; +$inputHeight: 60px; +$headingHeight: 32px; -.summary-box { +.gptPopup-summary-box { position: fixed; top: 115px; left: 75px; - width: 250px; - height: 200px; - min-height: 200px; - min-width: 180px; - + width: 100%; + height: 100%; + top: 0; + left: 0; + pointer-events: none; + border-top: solid gray 20px; border-radius: 16px; padding: 16px; padding-bottom: 0; + padding-top: 0px; z-index: 999; display: flex; flex-direction: column; @@ -24,25 +28,20 @@ $highlightedText: #82e0ff; background-color: #ffffff; box-shadow: 0 2px 5px #7474748d; color: $textgrey; - resize: both; /* Allows resizing */ - overflow: auto; - - .resize-handle { - width: 10px; - height: 10px; - background: #ccc; - position: absolute; - right: 0; - bottom: 0; - cursor: se-resize; - } + + .gptPopup-sortBox { + display: flex; + flex-direction: column; + height: calc(100% - $inputHeight - $headingHeight); + pointer-events: all; + } .summary-heading { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid $greyborder; - padding-bottom: 5px; + height: $headingHeight; .summary-text { font-size: 12px; @@ -63,95 +62,77 @@ $highlightedText: #82e0ff; cursor: pointer; } - .content-wrapper { + .gptPopup-content-wrapper { padding-top: 10px; min-height: 50px; - // max-height: 150px; - overflow-y: auto; - height: 100% + height: calc(100% - 32px); } - .btns-wrapper-gpt { - height: 100%; + .inputWrapper { display: flex; justify-content: center; align-items: center; - flex-direction: column; + height: $inputHeight; + background-color: white; + width: 100%; + pointer-events: all; - .inputWrapper{ - display: flex; - justify-content: center; - align-items: center; - height: 60px; - position: absolute; - bottom: 0; - width: 100%; - background-color: white; - - - } - - .searchBox-input{ + .searchBox-input { height: 40px; border-radius: 10px; - position: absolute; - bottom: 10px; + position: relative; border-color: #5b97ff; - width: 90% + width: 90%; } + } + .btns-wrapper-gpt { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; .chat-wrapper { display: flex; flex-direction: column; width: 100%; - max-height: calc(100vh - 80px); + height: 100%; overflow-y: auto; - padding-bottom: 60px; + padding-right: 5px; } - + .chat-bubbles { margin-top: 20px; display: flex; flex-direction: column; flex-grow: 1; } - + .chat-bubble { padding: 10px; margin-bottom: 10px; border-radius: 10px; max-width: 60%; } - + .user-message { background-color: #283d53; align-self: flex-end; color: whitesmoke; } - + .chat-message { background-color: #367ae7; align-self: flex-start; - color:whitesmoke; + color: whitesmoke; } - - - .summarizing { display: flex; align-items: center; } - - - - - - } - - .text-btn { &:hover { background-color: $button; @@ -198,22 +179,16 @@ $highlightedText: #82e0ff; color: #666; } - - - - @keyframes spin { to { transform: rotate(360deg); } } - - .image-content-wrapper { display: flex; flex-direction: column; - align-items: flex-start; + align-items: center; gap: 8px; padding-bottom: 16px; diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx index d5f5f620c..4dc45e6a0 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx +++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx @@ -1,401 +1,334 @@ +import { Button, IconButton, Toggle, ToggleType, Type } from '@dash/components'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Button, IconButton, Type } from 'browndash-components'; -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'; +import { AiOutlineSend } from 'react-icons/ai'; +import { CgCornerUpLeft } from 'react-icons/cg'; import ReactLoading from 'react-loading'; import { TypeAnimation } from 'react-type-animation'; import { ClientUtils } from '../../../../ClientUtils'; import { Doc } from '../../../../fields/Doc'; import { NumCast, StrCast } from '../../../../fields/Types'; import { Networking } from '../../../Network'; -import { GPTCallType, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT'; +import { DescriptionSeperator, DocSeperator, GPTCallType, GPTDocCommand, 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 { DictationButton } from '../../DictationButton'; import { ObservableReactComponent } from '../../ObservableReactComponent'; -import { DocumentView } from '../../nodes/DocumentView'; +import { TagItem } from '../../TagsView'; +import { ChatSortField, docSortings } from '../../collections/CollectionSubView'; +import { DocumentView, DocumentViewInternal } from '../../nodes/DocumentView'; +import { SmartDrawHandler } from '../../smartdraw/SmartDrawHandler'; import { AnchorMenu } from '../AnchorMenu'; import './GPTPopup.scss'; +import { FireflyImageDimensions } from '../../smartdraw/FireflyConstants'; +import { Upload } from '../../../../server/SharedMediaTypes'; +import { OpenWhere } from '../../nodes/OpenWhere'; +import { DrawingFillHandler } from '../../smartdraw/DrawingFillHandler'; +import { ImageField } from '../../../../fields/URLField'; +import { List } from '../../../../fields/List'; export enum GPTPopupMode { - SUMMARY, - EDIT, - IMAGE, - FLASHCARD, + SUMMARY, // summary of seleted document text + IMAGE, // generate image from image description DATA, - CARD, - SORT, - QUIZ, + GPT_MENU, // menu for choosing type of prompts user will provide + USER_PROMPT, // user prompts for sorting,filtering and asking about docs + QUIZ_RESPONSE, // user definitions or explanations to be evaluated by GPT + FIREFLY, // firefly image generation } -export enum GPTQuizType { - CURRENT = 0, - CHOOSE = 1, - MULTIPLE = 2, -} - -interface GPTPopupProps {} - @observer -export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { +export class GPTPopup extends ObservableReactComponent<object> { // eslint-disable-next-line no-use-before-define static Instance: GPTPopup; - private messagesEndRef: React.RefObject<HTMLDivElement>; - - @observable private chatMode: boolean = false; - private correlatedColumns: string[] = []; + static ChatTag = '#chat'; // tag used by GPT popup to filter docs + private _askDictation: DictationButton | 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: 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(); + } - @observable - public visible: boolean = false; - @action - public setVisible = (vis: boolean) => { - this.visible = vis; - }; - @observable - public loading: boolean = false; - @action - public setLoading = (loading: boolean) => { - this.loading = loading; - }; - @observable - public text: string = ''; - @action - public setText = (text: string) => { - this.text = text; - }; - @observable - public selectedText: string = ''; - @action - public setSelectedText = (text: string) => { - this.selectedText = text; - }; - @observable - public dataJson: string = ''; - public dataChatPrompt: string | undefined = undefined; - @action + public addDoc: ((doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean) | undefined; + public createFilteredDoc: (axes?: string[]) => boolean = () => false; + public setSidebarFieldKey = (id: string) => (this._sidebarFieldKey = id); + public setImgTargetDoc = (anchor: Doc) => (this._imgTargetDoc = anchor); + public setTextAnchor = (anchor: Doc) => (this._textAnchor = anchor); public setDataJson = (text: string) => { - if (text === '') this.dataChatPrompt = ''; - this.dataJson = text; - }; - - @observable - public imgDesc: string = ''; - @action - public setImgDesc = (text: string) => { - this.imgDesc = text; - }; - - @observable - public imgUrls: string[][] = []; - @action - public setImgUrls = (imgs: string[][]) => { - this.imgUrls = imgs; + if (text === '') this._dataChatPrompt = ''; + this._dataJson = text; }; - @observable - public mode: GPTPopupMode = GPTPopupMode.SUMMARY; - @action - public setMode = (mode: GPTPopupMode) => { - this.mode = mode; - }; - - @observable - public highlightRange: number[] = []; - @action callSummaryApi = () => {}; - - @observable - private done: boolean = false; - @action - public setDone = (done: boolean) => { - this.done = done; - this.chatMode = false; - }; - - @observable - private sortDone: boolean = false; // this is so redundant but the og done variable was causing weird unknown problems and im just a girl - - @action - public setSortDone = (done: boolean) => { - this.sortDone = done; - }; - - // change what can be a ref into a ref - @observable - private sidebarId: string = ''; - @action - public setSidebarId = (id: string) => { - this.sidebarId = id; - }; - - @observable - private imgTargetDoc: Doc | undefined; - @action - public setImgTargetDoc = (anchor: Doc) => { - this.imgTargetDoc = anchor; - }; - - @observable - private textAnchor: Doc | undefined; - @action - public setTextAnchor = (anchor: Doc) => { - this.textAnchor = anchor; - }; - - @observable - public sortDesc: string = ''; - - @action public setSortDesc = (t: string) => { - this.sortDesc = t; - }; - - @observable onSortComplete?: (sortResult: string, questionType: string, tag?: string) => void; - @observable onQuizRandom?: () => void; - @observable cardsDoneLoading = false; - - @action setCardsDoneLoading(done: boolean) { - console.log(done + 'HI HIHI'); - this.cardsDoneLoading = done; + componentDidUpdate() { + this._gptProcessing && this.setStopAnimatingResponse(false); } - - @observable sortRespText: string = ''; - - @action setSortRespText(resp: string) { - this.sortRespText = resp; + componentDidMount(): void { + reaction( + () => ({ selDoc: DocumentView.Selected().lastElement(), visible: SnappingManager.ChatVisible }), + ({ selDoc, visible }) => { + const hasChildDocs = visible && selDoc?.ComponentView?.hasChildDocs; + if (hasChildDocs) { + this._textToDocMap.clear(); + this.setCollectionContext(selDoc.Document); + this.onGptResponse = (sortResult: string, questionType: GPTDocCommand, args?: string) => this.processGptResponse(selDoc, this._textToDocMap, sortResult, questionType, args); + this.onQuizRandom = () => this.randomlyChooseDoc(selDoc.Document, hasChildDocs()); + this._documentDescriptions = Promise.all(hasChildDocs().map(doc => + Doc.getDescription(doc).then(text => this._textToDocMap.set(text.trim(), doc) && `${DescriptionSeperator}${text}${DescriptionSeperator}`) + )).then(docDescriptions => docDescriptions.join()); // prettier-ignore + } + }, + { fireImmediately: true } + ); } - @observable chatSortPrompt: string = ''; - - sortPromptChanged = action((e: React.ChangeEvent<HTMLInputElement>) => { - this.chatSortPrompt = e.target.value; - }); - - @observable quizAnswer: string = ''; - - quizAnswerChanged = action((e: React.ChangeEvent<HTMLInputElement>) => { - this.quizAnswer = e.target.value; - }); - - @observable conversationArray: string[] = ['Hi! In this pop up, you can ask ChatGPT questions about your documents and filter / sort them. ']; + @observable private _conversationArray: string[] = ['Hi! In this pop up, you can ask ChatGPT questions about your documents and filter / sort them. ']; + @observable private _fireflyArray: string[] = ['Hi! In this pop up, you can ask Firefly to create images. ']; + @observable private _chatEnabled: boolean = false; + @action private setChatEnabled = (start: boolean) => (this._chatEnabled = start); + @observable private _gptProcessing: boolean = false; + @action private setGptProcessing = (loading: boolean) => (this._gptProcessing = loading); + @observable private _responseText: string = ''; + @action private setResponseText = (text: string) => (this._responseText = text); + @observable private _imgUrls: string[][] = []; + @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 = ''; + @action setUserPrompt = (e: string) => (this._userPrompt = e); + @observable private _quizAnswer: string = ''; + @action setQuizAnswer = (e: string) => (this._quizAnswer = e); + @observable private _stopAnimatingResponse: boolean = false; + @action private setStopAnimatingResponse = (done: boolean) => (this._stopAnimatingResponse = done); + + @observable private _mode: GPTPopupMode = GPTPopupMode.SUMMARY; + @action public setMode = (mode: GPTPopupMode) => (this._mode = mode); + + onQuizRandom?: () => void; + onGptResponse?: (sortResult: string, questionType: GPTDocCommand, args?: string) => void; + NumberToCommandType = (questionType: string) => +questionType.split(' ')[0][0]; /** - * When the cards are in quiz mode in the card view, allows gpt to determine whether the user's answer was correct - * @returns + * 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 */ - generateQuiz = async () => { - this.setLoading(true); - this.setSortDone(false); - - const quizType = this.quizMode; - - const selected = DocumentView.SelectedDocs().lastElement(); - - const questionText = 'Question: ' + StrCast(selected['gptInputText']); - - if (StrCast(selected['gptRubric']) === '') { - const rubricText = 'Rubric: ' + (await this.generateRubric(StrCast(selected['gptInputText']), selected)); - } - - const rubricText = 'Rubric: ' + StrCast(selected['gptRubric']); - const queryText = questionText + ' UserAnswer: ' + this.quizAnswer + '. ' + 'Rubric' + rubricText; - - try { - const res = await gptAPICall(queryText, GPTCallType.QUIZ); - if (!res) { - console.error('GPT call failed'); - return; - } - console.log(res); - this.setQuizResp(res); - this.conversationArray.push(res); - - this.setLoading(false); - this.setSortDone(true); - } catch (err) { - console.error('GPT call failed'); - } - - if (this.onQuizRandom) { - this.onQuizRandom(); - } - }; + processGptResponse = (docView: DocumentView, textToDocMap: Map<string, Doc>, gptOutput: string, questionType: GPTDocCommand, args?: string) => + undoable(() => { + switch (questionType) { // reset collection based on question typefc + case GPTDocCommand.Sort: + docView.Document[docView.ComponentView?.fieldKey + '_sort'] = docSortings.Chat; + break; + case GPTDocCommand.Filter: + docView.ComponentView?.hasChildDocs?.().forEach(d => TagItem.removeTagFromDoc(d, GPTPopup.ChatTag)); + break; + } // prettier-ignore + + gptOutput.split('======').filter(item => item.trim() !== '') // Split output into individual document contents + .map(docContentRaw => textToDocMap.get(docContentRaw.replace(/\n/g, ' ').trim())) // the find the corresponding Doc using textToDoc map + .filter(doc => doc).map(doc => doc!) // filter out undefined values + .forEach((doc, index) => { + switch (questionType) { + case GPTDocCommand.Sort: + doc[ChatSortField] = index; + break; + case GPTDocCommand.AssignTags: + if (args) { + const hashTag = args.startsWith('#') ? args : '#' + args[0].toLowerCase() + args.slice(1); + const filterTag = Doc.MyFilterHotKeys.map(key => StrCast(key.toolType)).find(key => key.includes(args)) ?? hashTag; + TagItem.addTagToDoc(doc, filterTag); + } + break; + case GPTDocCommand.Filter: + TagItem.addTagToDoc(doc, GPTPopup.ChatTag); + Doc.setDocFilter(docView.Document, 'tags', GPTPopup.ChatTag, 'check'); + break; + } + }); // prettier-ignore + }, '')(); /** - * Generates a rubric by which to compare the user's answer to - * @param inputText user's answer - * @param doc the doc the user is providing info about - * @returns gpt's response + * When in quiz mode, randomly selects a document */ - generateRubric = async (inputText: string, doc: Doc) => { - try { - const res = await gptAPICall(inputText, GPTCallType.RUBRIC); - doc['gptRubric'] = res; - return res; - } catch (err) { - console.error('GPT call failed'); - } - }; - - @observable private regenerateCallback: (() => Promise<void>) | null = null; - + randomlyChooseDoc = (doc: Doc, childDocs: Doc[]) => DocumentView.getDocumentView(childDocs[Math.floor(Math.random() * childDocs.length)])?.select(false); /** - * Callback function that causes the card view to update the childpair string list - * @param callback + * Generates a rubric for evaluating the user's description of the document's text + * @param doc the doc the user is providing info about + * @returns gpt's response rubric */ - @action public setRegenerateCallback(callback: () => Promise<void>) { - this.regenerateCallback = callback; - } - - 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; - - @observable quizRespText: string = ''; - - @action setQuizResp(resp: string) { - this.quizRespText = resp; - } + generateRubric = (doc: Doc) => + StrCast(doc.gptRubric) + ? Promise.resolve(StrCast(doc.gptRubric)) + : Doc.getDescription(doc).then(desc => + gptAPICall(desc, GPTCallType.MAKERUBRIC) + .then(res => (doc.gptRubric = res)) + .catch(err => console.error('GPT call failed', err)) + ); /** - * Generates a response to the user's question depending on the type of their question + * When the cards are in quiz mode in the card view, allows gpt to determine whether the user's answer was correct + * @param doc the doc the user is providing info about + * @param quizAnswer the user's answer/description for the document + * @returns */ - generateCard = async () => { - console.log(this.chatSortPrompt + 'USER PROMPT'); - this.setLoading(true); - this.setSortDone(false); - - if (this.regenerateCallback) { - await this.regenerateCallback(); - } - - try { - // const res = await gptAPICall(this.sortDesc, GPTCallType.SORT, this.chatSortPrompt); - const questionType = await gptAPICall(this.chatSortPrompt, GPTCallType.TYPE); - const questionNumber = questionType.split(' ')[0]; - console.log(questionType); - let res = ''; - - switch (questionNumber) { - case '1': - case '2': - case '4': - res = await gptAPICall(this.sortDesc, GPTCallType.SUBSET, this.chatSortPrompt); - break; - case '6': - res = await gptAPICall(this.sortDesc, GPTCallType.SORT, this.chatSortPrompt); - break; - default: - const selected = DocumentView.SelectedDocs().lastElement(); - const questionText = StrCast(selected!['gptInputText']); - - res = await gptAPICall(questionText, GPTCallType.INFO, this.chatSortPrompt); - break; - } - - // Trigger the callback with the result - if (this.onSortComplete) { - this.onSortComplete(res || 'Something went wrong :(', questionNumber, questionType.split(' ').slice(1).join(' ')); - - let explanation = res; - - if (questionType != '5' && questionType != '3') { - // Extract explanation surrounded by ------ at the top or both at the top and bottom - const explanationMatch = res.match(/------\s*([\s\S]*?)\s*(?:------|$)/) || []; - explanation = explanationMatch[1] ? explanationMatch[1].trim() : 'No explanation found'; - } - - // Set the extracted explanation to sortRespText - this.setSortRespText(explanation); - this.conversationArray.push(this.sortRespText); - this.scrollToBottom(); - - console.log(res); - } - } catch (err) { - console.error(err); + generateQuizAnswerAnalysis = (doc: Doc, quizAnswer: string) => + this.generateRubric(doc).then(() => + Doc.getDescription(doc).then(desc => + gptAPICall( + `Question: ${desc}; + UserAnswer: ${quizAnswer}; + Rubric: ${StrCast(doc.gptRubric)}`, + GPTCallType.QUIZDOC + ).then(res => { + this._conversationArray.push(res || 'GPT provided no answer'); + this.onQuizRandom?.(); + }) + .catch(err => console.error('GPT call failed', err)) + )) // prettier-ignore + + generateFireflyImage = (imgDesc: string) => { + const selView = DocumentView.Selected().lastElement(); + const selDoc = selView?.Document; + if (selDoc && (selView._props.renderDepth > 1 || selDoc[Doc.LayoutFieldKey(selDoc)] instanceof ImageField)) { + const oldPrompt = StrCast(selDoc.ai_firefly_prompt, StrCast(selDoc.title)); + const newPrompt = oldPrompt ? `${oldPrompt} ~~~ ${imgDesc}` : imgDesc; + return DrawingFillHandler.drawingToImage(selDoc, 100, newPrompt, selDoc) + .then(action(() => (this._userPrompt = ''))) + .catch(e => { + alert(e); + return undefined; + }); } - - this.setLoading(false); - this.setSortDone(true); + return SmartDrawHandler.CreateWithFirefly(imgDesc, FireflyImageDimensions.Square, 0) + .then( + action(doc => { + doc instanceof Doc && DocumentViewInternal.addDocTabFunc(doc, OpenWhere.addRight); + this._userPrompt = ''; + }) + ) + .catch(e => { + alert(e); + return undefined; + }); }; + /** + * Generates a response to the user's question about the docs in the collection. + * The type of response depends on the chat's analysis of the type of their question + * @param userPrompt the user's input that chat will respond to + */ + generateUserPromptResponse = (userPrompt: string) => + gptAPICall(userPrompt, GPTCallType.COMMANDTYPE, undefined, true).then((commandType, args = commandType.split(' ').slice(1).join(' ')) => + (async () => { + switch (this.NumberToCommandType(commandType)) { + case GPTDocCommand.AssignTags: + case GPTDocCommand.Filter: return this._documentDescriptions?.then(descs => gptAPICall(userPrompt, GPTCallType.SUBSETDOCS, descs)) ?? ""; + case GPTDocCommand.Sort: return this._documentDescriptions?.then(descs => gptAPICall(userPrompt, GPTCallType.SORTDOCS, descs)) ?? ""; + default: return Doc.getDescription(DocumentView.SelectedDocs().lastElement()).then(desc => gptAPICall(userPrompt, GPTCallType.DOCINFO, desc)); + } // prettier-ignore + })().then( + action(res => { + // Trigger the callback with the result + this.onGptResponse?.(res || 'Something went wrong :(', this.NumberToCommandType(commandType), args); + this._conversationArray.push( + this.NumberToCommandType(commandType) === GPTDocCommand.GetInfo ? 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)) + ).catch(err => console.log(err)); // prettier-ignore /** * Generates a Dalle image and uploads it to the server. */ - generateImage = async () => { - if (this.imgDesc === '') return undefined; + generateImage = (imgDesc: string, imgTarget: Doc, addToCollection?: (doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) => { + this._imgTargetDoc = imgTarget; + SnappingManager.SetChatVisible(true); + this.addDoc = addToCollection; this.setImgUrls([]); this.setMode(GPTPopupMode.IMAGE); - this.setVisible(true); - this.setLoading(true); - - try { - const imageUrls = await gptImageCall(this.imgDesc); - if (imageUrls && imageUrls[0]) { - const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [imageUrls[0]] }); - const source = ClientUtils.prepend(result.accessPaths.agnostic.client); - this.setImgUrls([[imageUrls[0], source]]); - } - } catch (err) { - console.error(err); - } - this.setLoading(false); - return undefined; + this.setGptProcessing(true); + this._imageDescription = imgDesc; + + return gptImageCall(imgDesc) + .then(imageUrls => + imageUrls?.[0] + ? Networking.PostToServer('/uploadRemoteImage', { sources: [imageUrls[0]] }).then(res => { + const source = ClientUtils.prepend((res as Upload.FileInformation[])[0].accessPaths.agnostic.client); + return this.setImgUrls([[imageUrls[0]!, source]]); + }) + : undefined + ) + .catch(err => console.error(err)) + .finally(() => this.setGptProcessing(false)); }; /** - * Completes an API call to generate a summary of - * this.selectedText in the popup. + * Completes an API call to generate a summary of the specified text + * + * @param text the text to summarizz */ - generateSummary = async () => { - GPTPopup.Instance.setVisible(true); - GPTPopup.Instance.setMode(GPTPopupMode.SUMMARY); - GPTPopup.Instance.setLoading(true); - - try { - const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY); - GPTPopup.Instance.setText(res || 'Something went wrong.'); - } catch (err) { - console.error(err); - } - GPTPopup.Instance.setLoading(false); + generateSummary = (text: string) => { + SnappingManager.SetChatVisible(true); + this._textToSummarize = text; + this.setMode(GPTPopupMode.SUMMARY); + this.setGptProcessing(true); + return gptAPICall(text, GPTCallType.SUMMARY) + .then(res => this.setResponseText(res || 'Something went wrong.')) + .catch(err => console.error(err)) + .finally(() => this.setGptProcessing(false)); }; /** * Completes an API call to generate an analysis of * this.dataJson in the popup. */ - generateDataAnalysis = async () => { - GPTPopup.Instance.setVisible(true); - GPTPopup.Instance.setLoading(true); - try { - const res = await gptAPICall(this.dataJson, GPTCallType.DATA, this.dataChatPrompt); - const json = JSON.parse(res! as string); - const keys = Object.keys(json); - this.correlatedColumns = []; - this.correlatedColumns.push(json[keys[0]]); - this.correlatedColumns.push(json[keys[1]]); - GPTPopup.Instance.setText(json[keys[2]] || 'Something went wrong.'); - } catch (err) { - console.error(err); - } - GPTPopup.Instance.setLoading(false); + generateDataAnalysis = () => { + this.setGptProcessing(true); + return gptAPICall(this._dataJson, GPTCallType.DATA, this._dataChatPrompt) + .then(res => { + const json = JSON.parse(res! as string); + const keys = Object.keys(json); + this._correlatedColumns = []; + this._correlatedColumns.push(json[keys[0]]); + this._correlatedColumns.push(json[keys[1]]); + this.setResponseText(json[keys[2]] || 'Something went wrong.'); + }) + .catch(err => console.error(err)) + .finally(() => this.setGptProcessing(false)); }; /** * Transfers the summarization text to a sidebar annotation text document. */ private transferToText = () => { - const newDoc = Docs.Create.TextDocument(this.text.trim(), { + const newDoc = Docs.Create.TextDocument(this._responseText.trim(), { _width: 200, _height: 50, _layout_fitWidth: true, _layout_autoHeight: true, }); - this.addDoc(newDoc, this.sidebarId); - // newDoc.data = 'Hello world'; + this.addDoc?.(newDoc, this._sidebarFieldKey); const anchor = AnchorMenu.Instance?.GetAnchor(undefined, false); if (anchor) { DocUtils.MakeLink(newDoc, anchor, { @@ -407,80 +340,44 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { /** * Creates a histogram to show the correlation relationship that was found */ - private createVisualization = () => { - this.createFilteredDoc(this.correlatedColumns); - }; + private createVisualization = () => this.createFilteredDoc(this._correlatedColumns); /** * Transfers the image urls to actual image docs */ private transferToImage = (source: string) => { - const textAnchor = this.textAnchor ?? this.imgTargetDoc; - if (!textAnchor) return; - const newDoc = Docs.Create.ImageDocument(source, { - x: NumCast(textAnchor.x) + NumCast(textAnchor._width) + 10, - y: NumCast(textAnchor.y), - _height: 200, - _width: 200, - data_nativeWidth: 1024, - data_nativeHeight: 1024, - }); - if (Doc.IsInMyOverlay(textAnchor)) { - newDoc.overlayX = textAnchor.x; - newDoc.overlayY = NumCast(textAnchor.y) + NumCast(textAnchor._height); - Doc.AddToMyOverlay(newDoc); - } else { - this.addToCollection?.(newDoc); - } - // Create link between prompt and image - DocUtils.MakeLink(textAnchor, newDoc, { link_relationship: 'Image Prompt' }); - }; - - /** - * Creates a chatbox for analyzing data so that users can ask specific questions. - */ - private chatWithAI = () => { - this.chatMode = true; - }; - dataPromptChanged = action((e: React.ChangeEvent<HTMLInputElement>) => { - this.dataChatPrompt = e.target.value; - }); - - private getPreviewUrl = (source: string) => source.split('.').join('_m.'); - - constructor(props: GPTPopupProps) { - super(props); - makeObservable(this); - GPTPopup.Instance = this; - this.messagesEndRef = React.createRef(); - } - - scrollToBottom = () => { - setTimeout(() => { - // Code to execute after 1 second (1000 ms) - if (this.messagesEndRef.current) { - this.messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); + const textAnchor = this._textAnchor ?? this._imgTargetDoc; + if (textAnchor) { + const newDoc = Docs.Create.ImageDocument(source, { + x: NumCast(textAnchor.x) + NumCast(textAnchor._width) + 10, + y: NumCast(textAnchor.y), + _height: 200, + _width: 200, + ai: 'dall-e', + tags: new List<string>(['@ai']), + data_nativeWidth: 1024, + data_nativeHeight: 1024, + }); + if (Doc.IsInMyOverlay(textAnchor)) { + newDoc.overlayX = textAnchor.x; + newDoc.overlayY = NumCast(textAnchor.y) + NumCast(textAnchor._height); + Doc.AddToMyOverlay(newDoc); + } else { + this.addDoc?.(newDoc); } - }, 50); - }; - - componentDidUpdate = () => { - if (this.loading) { - this.setDone(false); + // Create link between prompt and image + DocUtils.MakeLink(textAnchor, newDoc, { link_relationship: 'Image Prompt' }); } }; - @observable quizMode: GPTQuizType = GPTQuizType.CURRENT; - @action setQuizMode(g: GPTQuizType) { - this.quizMode = g; - } + scrollToBottom = () => setTimeout(() => this._messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }), 50); - cardMenu = () => ( + gptMenu = () => ( <div className="btns-wrapper-gpt"> <Button - tooltip="Have ChatGPT sort, tag, define, or filter your cards for you!" - text="Modify/Sort Cards!" - onClick={() => this.setMode(GPTPopupMode.SORT)} + tooltip="Ask Firefly to create images" + text="Ask Firefly" + onClick={() => this.setMode(GPTPopupMode.FIREFLY)} color={StrCast(Doc.UserDoc().userVariantColor)} type={Type.TERT} style={{ @@ -493,163 +390,183 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { }} /> <Button - tooltip="Test your knowledge with ChatGPT!" - text="Quiz 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} + style={{ + width: '100%', + height: '40%', + textAlign: 'center', + color: '#ffffff', + fontSize: '16px', + marginBottom: '10px', + }} + /> + <Button + tooltip="Test your knowledge by verifying answers with ChatGPT" + text="Take Quiz" onClick={() => { - this.conversationArray = ['Define the selected card!']; - this.setMode(GPTPopupMode.QUIZ); - if (this.onQuizRandom) { - this.onQuizRandom(); - } + this._conversationArray = ['Define the selected card!']; + this.setMode(GPTPopupMode.QUIZ_RESPONSE); + this.onQuizRandom?.(); }} color={StrCast(Doc.UserDoc().userVariantColor)} type={Type.TERT} style={{ width: '100%', + height: '40%', textAlign: 'center', color: '#ffffff', fontSize: '16px', - height: '40%', }} /> </div> ); - handleKeyPress = async (e: React.KeyboardEvent, isSort: boolean) => { + callGpt = action((mode: GPTPopupMode) => { + this.setGptProcessing(true); + switch (mode) { + case GPTPopupMode.FIREFLY: + this._fireflyArray.push(this._userPrompt); + return this.generateFireflyImage(this._userPrompt).then(action(() => (this._userPrompt = ''))); + case GPTPopupMode.USER_PROMPT: + this._conversationArray.push(this._userPrompt); + return this.generateUserPromptResponse(this._userPrompt).then(action(() => (this._userPrompt = ''))); + case GPTPopupMode.QUIZ_RESPONSE: + this._conversationArray.push(this._quizAnswer); + return this.generateQuizAnswerAnalysis(DocumentView.SelectedDocs().lastElement(), this._quizAnswer).then(action(() => (this._quizAnswer = ''))); + } + }); + + @action + handleKeyPress = async (e: React.KeyboardEvent, mode: GPTPopupMode) => { + this._askDictation?.stopDictation(); if (e.key === 'Enter') { e.stopPropagation(); - if (isSort) { - this.conversationArray.push(this.chatSortPrompt); - await this.generateCard(); - this.chatSortPrompt = ''; - } else { - this.conversationArray.push(this.quizAnswer); - await this.generateQuiz(); - this.quizAnswer = ''; - } - - this.scrollToBottom(); + this.callGpt(mode)?.then(() => { + this.setGptProcessing(false); + this.scrollToBottom(); + }); } }; - cardActual = (opt: GPTPopupMode) => { - const isSort = opt === GPTPopupMode.SORT; - return ( - <div className="btns-wrapper-gpt"> - <div className="chat-wrapper"> - <div className="chat-bubbles"> - {this.conversationArray.map((message, index) => ( - <div key={index} className={`chat-bubble ${index % 2 === 1 ? 'user-message' : 'chat-message'}`}> - {message} - </div> - ))} - {(!this.cardsDoneLoading || this.loading) && <div className={`chat-bubble chat-message`}>...</div>} - </div> - - <div ref={this.messagesEndRef} style={{ height: '100px' }} /> + gptUserInput = () => ( + <div className="btns-wrapper-gpt"> + <div className="chat-wrapper"> + <div className="chat-bubbles"> + {(this._mode === GPTPopupMode.FIREFLY ? this._fireflyArray : this._conversationArray).map((message, index) => ( + <div key={index} className={`chat-bubble ${index % 2 === 1 ? 'user-message' : 'chat-message'}`}> + {message} + </div> + ))} + {this._gptProcessing && <div className="chat-bubble chat-message">...</div>} </div> - <div className="inputWrapper"> - <input - className="searchBox-input" - defaultValue="" - value={isSort ? this.chatSortPrompt : this.quizAnswer} // Controlled input - autoComplete="off" - onChange={isSort ? this.sortPromptChanged : this.quizAnswerChanged} - onKeyDown={e => { - this.handleKeyPress(e, isSort); - }} - type="text" - placeholder={`${isSort ? 'Have ChatGPT sort, tag, define, or filter your cards for you!' : 'Define the selected card!'}`} - /> - </div> + <div ref={this._messagesEndRef} style={{ height: '100px' }} /> </div> - ); - }; + </div> + ); - sortBox = () => ( - <div style={{ height: '80%' }}> - {this.heading(this.mode === GPTPopupMode.SORT ? 'SORTING' : 'QUIZ')} - <> - {!this.cardsDoneLoading ? ( - <div className="content-wrapper"> - <div className="loading-spinner"> - <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> - {this.loading ? <span>Loading...</span> : <span>Reading Cards...</span>} - </div> - </div> - ) : this.mode === GPTPopupMode.CARD ? ( - this.cardMenu() - ) : ( - this.cardActual(this.mode) - ) // Call the functions to render JSX - } - </> + promptBox = (heading: string, value: string, onChange: (e: string) => string, placeholder: string) => ( + <> + <div className="gptPopup-sortBox"> + {this.heading(heading)} + {this.gptUserInput()} + </div> + <div className="inputWrapper"> + <input + className="searchBox-input" + value={value} // Controlled input + autoComplete="off" + onChange={e => onChange(e.target.value)} + onKeyDown={e => this.handleKeyPress(e, this._mode)} + type="text" + placeholder={placeholder} + /> + <Button // + text="Send" + type={Type.TERT} + icon={<AiOutlineSend />} + iconPlacement="right" + color={SnappingManager.userVariantColor} + onClick={() => this.callGpt(this._mode)} + /> + <DictationButton ref={r => (this._askDictation = r)} setInput={onChange} /> + </div> + </> + ); + + menuBox = () => ( + <div className="gptPopup-sortBox"> + {this.heading('CHOOSE')} + {this.gptMenu()} </div> ); imageBox = () => ( - <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', overflow: 'auto', height: '100%', pointerEvents: 'all' }}> {this.heading('GENERATED IMAGE')} <div className="image-content-wrapper"> - {this.imgUrls.map((rawSrc, i) => ( - <div key={rawSrc[0] + i} className="img-wrapper"> - <div className="img-container"> - <img key={rawSrc[0]} src={rawSrc[0]} width={150} height={150} alt="dalle generation" /> + {this._imgUrls.map((rawSrc, i) => ( + <> + <div key={rawSrc[0] + i} className="img-wrapper"> + <div className="img-container"> + <img key={rawSrc[0]} src={rawSrc[0]} width={150} height={150} alt="dalle generation" /> + </div> </div> - <div className="btn-container"> + <div key={rawSrc[0] + i + 'btn'} className="btn-container"> <Button text="Save Image" onClick={() => this.transferToImage(rawSrc[1])} color={StrCast(Doc.UserDoc().userColor)} type={Type.TERT} /> </div> - </div> + </> ))} </div> - {!this.loading && <IconButton tooltip="Generate Again" onClick={this.generateImage} 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> ); summaryBox = () => ( <> - <div> + <div style={{ height: 'calc(100% - 60px)', overflow: 'auto' }}> {this.heading('SUMMARY')} - <div className="content-wrapper"> - {!this.loading && - (!this.done ? ( + <div className="gptPopup-content-wrapper"> + {!this._gptProcessing && + (!this._stopAnimatingResponse ? ( <TypeAnimation speed={50} sequence={[ - this.text, + this._responseText, () => { - setTimeout(() => { - this.setDone(true); - }, 500); + setTimeout(() => this.setStopAnimatingResponse(true), 500); }, ]} /> ) : ( - this.text + this._responseText ))} </div> </div> - {!this.loading && ( - <div className="btns-wrapper"> - {this.done ? ( + {!this._gptProcessing && ( + <div className="btns-wrapper" style={{ position: 'absolute', bottom: 0, width: 'calc(100% - 32px)' }}> + {this._stopAnimatingResponse ? ( <> - <IconButton tooltip="Generate Again" onClick={this.generateSummary /* this.callSummaryApi */} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(SettingsManager.userVariantColor)} /> + <IconButton tooltip="Generate Again" onClick={() => this.generateSummary(this._textToSummarize + ' ')} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(SettingsManager.userVariantColor)} /> <Button tooltip="Transfer to text" text="Transfer To Text" onClick={this.transferToText} color={StrCast(SettingsManager.userVariantColor)} type={Type.TERT} /> </> ) : ( <div className="summarizing"> <span>Summarizing</span> <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} /> - <Button - text="Stop Animation" - onClick={() => { - this.setDone(true); - }} - color={StrCast(SettingsManager.userVariantColor)} - type={Type.TERT} - /> + <Button text="Stop Animation" onClick={() => this.setStopAnimatingResponse(true)} color={StrCast(SettingsManager.userVariantColor)} type={Type.TERT} /> </div> )} </div> @@ -661,33 +578,31 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { <> <div> {this.heading('ANALYSIS')} - <div className="content-wrapper"> - {!this.loading && - (!this.done ? ( + <div className="gptPopup-content-wrapper"> + {!this._gptProcessing && + (!this._stopAnimatingResponse ? ( <TypeAnimation speed={50} sequence={[ - this.text, + this._responseText, () => { - setTimeout(() => { - this.setDone(true); - }, 500); + setTimeout(() => this.setStopAnimatingResponse(true), 500); }, ]} /> ) : ( - this.text + this._responseText ))} </div> </div> - {!this.loading && ( + {!this._gptProcessing && ( <div className="btns-wrapper"> - {this.done ? ( - this.chatMode ? ( + {this._stopAnimatingResponse ? ( + this._chatEnabled ? ( <input defaultValue="" autoComplete="off" - onChange={this.dataPromptChanged} + onChange={e => (this._dataChatPrompt = e.target.value)} onKeyDown={e => { e.key === 'Enter' ? this.generateDataAnalysis() : null; e.stopPropagation(); @@ -701,21 +616,14 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { ) : ( <> <Button tooltip="Transfer to text" text="Transfer To Text" onClick={this.transferToText} color={StrCast(SnappingManager.userVariantColor)} type={Type.TERT} /> - <Button tooltip="Chat with AI" text="Chat with AI" onClick={this.chatWithAI} color={StrCast(SnappingManager.userVariantColor)} type={Type.TERT} /> + <Button tooltip="Chat with AI" text="Chat with AI" onClick={() => this.setChatEnabled(true)} color={StrCast(SnappingManager.userVariantColor)} type={Type.TERT} /> </> ) ) : ( <div className="summarizing"> <span>Summarizing</span> <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} /> - <Button - text="Stop Animation" - onClick={() => { - this.setDone(true); - }} - color={StrCast(SnappingManager.userVariantColor)} - type={Type.TERT} - /> + <Button text="Stop Animation" onClick={() => this.setStopAnimatingResponse(true)} color={StrCast(SnappingManager.userVariantColor)} type={Type.TERT} /> </div> )} </div> @@ -724,62 +632,53 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { ); aiWarning = () => - this.done ? ( + !this._stopAnimatingResponse ? null : ( <div className="ai-warning"> <FontAwesomeIcon icon="exclamation-circle" size="sm" style={{ paddingRight: '5px' }} /> AI generated responses can contain inaccurate or misleading content. </div> - ) : null; + ); heading = (headingText: string) => ( <div className="summary-heading"> <label className="summary-text">{headingText}</label> - {this.loading ? ( + {this._gptProcessing ? ( <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} /> ) : ( <> - {(this.mode === GPTPopupMode.SORT || this.mode === GPTPopupMode.QUIZ) && ( - <IconButton color={StrCast(SettingsManager.userVariantColor)} tooltip="back" icon={<CgCornerUpLeft size="16px" />} onClick={() => (this.mode = GPTPopupMode.CARD)} style={{ right: '50px', position: 'absolute' }} /> - )} - <IconButton - color={StrCast(SettingsManager.userVariantColor)} - tooltip="close" - icon={<CgClose size="16px" />} - onClick={() => { - this.setVisible(false); - }} + <Toggle + tooltip="Clear Chat filter" + toggleType={ToggleType.BUTTON} + type={Type.PRIM} + toggleStatus={Doc.hasDocFilter(this._collectionContext, 'tags', GPTPopup.ChatTag)} + text={Doc.hasDocFilter(this._collectionContext, 'tags', GPTPopup.ChatTag) ? 'filtered' : ''} + color={Doc.hasDocFilter(this._collectionContext, 'tags', GPTPopup.ChatTag) ? 'red' : 'transparent'} + onClick={() => this._collectionContext && Doc.setDocFilter(this._collectionContext, 'tags', GPTPopup.ChatTag, 'remove')} /> + {[GPTPopupMode.USER_PROMPT, GPTPopupMode.QUIZ_RESPONSE, GPTPopupMode.FIREFLY].includes(this._mode) && ( + <IconButton color={StrCast(SettingsManager.userVariantColor)} tooltip="back" icon={<CgCornerUpLeft size="16px" />} onClick={() => (this._mode = GPTPopupMode.GPT_MENU)} /> + )} </> )} </div> ); render() { - let content; - - switch (this.mode) { - case GPTPopupMode.SUMMARY: - content = this.summaryBox(); - break; - case GPTPopupMode.DATA: - content = this.dataAnalysisBox(); - break; - case GPTPopupMode.IMAGE: - content = this.imageBox(); - break; - case GPTPopupMode.SORT: - case GPTPopupMode.CARD: - case GPTPopupMode.QUIZ: - content = this.sortBox(); - break; - default: - content = null; - } - return ( - <div className="summary-box" style={{ display: this.visible ? 'flex' : 'none' }}> - {content} - <div className="resize-handle" /> + <div className="gptPopup-summary-box" style={{ display: SnappingManager.ChatVisible ? 'flex' : 'none', overflow: 'auto' }}> + {(() => { + //prettier-ignore + switch (this._mode) { + case GPTPopupMode.USER_PROMPT: return this.promptBox("ASK", this._userPrompt, this.setUserPrompt, 'Ask GPT to sort, tag, define, or filter your documents for you!'); + case GPTPopupMode.FIREFLY: return this.promptBox("CREATE", this._userPrompt, this.setUserPrompt, StrCast(DocumentView.Selected().lastElement()?.Document.ai_firefly_prompt, 'Ask Firefly to generate images')); + case GPTPopupMode.QUIZ_RESPONSE: return this.promptBox("QUIZ", this._quizAnswer, this.setQuizAnswer, 'Describe/answer the selected document!'); + case GPTPopupMode.GPT_MENU: return this.menuBox(); + case GPTPopupMode.SUMMARY: return this.summaryBox(); + case GPTPopupMode.DATA: return this.dataAnalysisBox(); + case GPTPopupMode.IMAGE: return this.imageBox(); + default: return null; + } + })()} </div> ); } diff --git a/src/client/views/pdf/PDFViewer.scss b/src/client/views/pdf/PDFViewer.scss index d3dd9f727..030251762 100644 --- a/src/client/views/pdf/PDFViewer.scss +++ b/src/client/views/pdf/PDFViewer.scss @@ -7,6 +7,10 @@ left: 0; } +:root { + --devicePixelRatio: 1; // the actual value of this will be set in PDFViewer.tsx; +} + .pdfViewerDash, .pdfViewerDash-interactive { position: absolute; @@ -19,13 +23,16 @@ overflow-x: hidden; transform-origin: top left; - // .canvasWrapper { - // transform: scale(0.75); - // transform-origin: top left; - // } + .annotationLayer { + transform: scale(var(--devicePixelRatio)); + } .textLayer { opacity: unset; mix-blend-mode: multiply; // bcz: makes text fuzzy! + transform: scale(var(--devicePixelRatio)); + } + [data-main-rotation='90'] { + transform: scale(var(--devicePixelRatio)) rotate(90deg) translateY(-100%); } .textLayer ::selection { background: #accef76a; @@ -43,6 +50,7 @@ .page { position: relative; border: unset; + height: 100% !important; } .pdfViewerDash-text-selected { @@ -107,3 +115,22 @@ .pdfViewerDash-interactive { pointer-events: all; } + +.loading-spinner { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + z-index: 200; + font-size: 20px; + font-weight: bold; + color: #17175e; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index 7a86ee802..a50526223 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -11,7 +11,7 @@ import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; import { Cast, NumCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; -import { emptyFunction } from '../../../Utils'; +import { emptyFunction, numberRange, unimplementedFunction } from '../../../Utils'; import { DocUtils } from '../../documents/DocUtils'; import { SnappingManager } from '../../util/SnappingManager'; import { MarqueeOptionsMenu } from '../collections/collectionFreeForm'; @@ -28,8 +28,9 @@ import { AnchorMenu } from './AnchorMenu'; import { Annotation } from './Annotation'; import { GPTPopup } from './GPTPopup/GPTPopup'; import './PDFViewer.scss'; -// The workerSrc property shall be specified. -// Pdfjs.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@4.4.168/build/pdf.worker.mjs'; +import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT'; +import ReactLoading from 'react-loading'; +import { Transform } from '../../util/Transform'; interface IViewerProps extends FieldViewProps { pdfBox: PDFBox; @@ -40,7 +41,7 @@ interface IViewerProps extends FieldViewProps { pdf: Pdfjs.PDFDocumentProxy; url: string; sidebarAddDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean; - loaded?: (nw: number, nh: number, np: number) => void; + loaded: (p: { width: number; height: number }, pages: number) => void; // eslint-disable-next-line no-use-before-define setPdfViewer: (view: PDFViewer) => void; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); @@ -64,6 +65,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { @observable _textSelecting = true; @observable _showWaiting = true; @observable Index: number = -1; + @observable private _loading = false; private _pdfViewer!: PDFJSViewer.PDFViewer; private _styleRule: number | undefined; // stylesheet rule for making hyperlinks clickable @@ -145,32 +147,30 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { } }; - @observable _scrollHeight = 0; + @computed get _scrollHeight() { + return this._pageSizes.reduce((size, page) => size + page.height, 0); + } - @action - initialLoad = async () => { + initialLoad = () => { + const page0or180 = (page: { rotate: number }) => page.rotate === 0 || page.rotate === 180; if (this._pageSizes.length === 0) { - this._pageSizes = Array<{ width: number; height: number }>(this._props.pdf.numPages); - await Promise.all( - this._pageSizes.map((val, i) => - this._props.pdf.getPage(i + 1).then( - action((page: Pdfjs.PDFPageProxy) => { - const page0or180 = page.rotate === 0 || page.rotate === 180; - this._pageSizes.splice(i, 1, { - width: page.view[page0or180 ? 2 : 3] - page.view[page0or180 ? 0 : 1], - height: page.view[page0or180 ? 3 : 2] - page.view[page0or180 ? 1 : 0], - }); - if (i === this._props.pdf.numPages - 1) { - this._props.loaded?.(page.view[page0or180 ? 2 : 3] - page.view[page0or180 ? 0 : 1], page.view[page0or180 ? 3 : 2] - page.view[page0or180 ? 1 : 0], this._props.pdf.numPages); - } - }) - ) + const devicePixelRatio = window.devicePixelRatio; + document.documentElement?.style.setProperty('--devicePixelRatio', window.devicePixelRatio.toString()); // set so that css can use this to adjust various PDFJs divs + Promise.all( + numberRange(this._props.pdf.numPages).map(i => + this._props.pdf.getPage(i + 1).then(page => ({ + width: (page.view[page0or180(page) ? 2 : 3] - page.view[page0or180(page) ? 0 : 1]) * devicePixelRatio, + height: (page.view[page0or180(page) ? 3 : 2] - page.view[page0or180(page) ? 1 : 0]) * devicePixelRatio, + })) ) + ).then( + action(pages => { + this._pageSizes = pages; + this._props.loaded(pages.lastElement(), this._props.pdf.numPages); + this.createPdfViewer(); + }) ); } - runInAction(() => { - this._scrollHeight = (this._pageSizes.reduce((size, page) => size + page.height, 0) * 96) / 72; - }); }; _scrollStopper: undefined | (() => void); @@ -196,14 +196,12 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { crop = (region: Doc | undefined, addCrop?: boolean) => this._props.crop(region, addCrop); @action - setupPdfJsViewer = async () => { + setupPdfJsViewer = () => { if (this._viewerIsSetup) return; this._viewerIsSetup = true; this._showWaiting = true; this._props.setPdfViewer(this); - await this.initialLoad(); - - this.createPdfViewer(); + this.initialLoad(); }; pagesinit = () => { @@ -376,7 +374,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { if ((e.button !== 0 || e.altKey) && this._props.isContentActive()) { this._setPreviewCursor?.(e.clientX, e.clientY, true, false, this._props.Document); } - if (!e.altKey && e.button === 0 && this._props.isContentActive() && ![InkTool.Highlighter, InkTool.Pen, InkTool.Write].includes(Doc.ActiveTool)) { + if (!e.altKey && e.button === 0 && this._props.isContentActive() && Doc.ActiveTool !== InkTool.Ink) { this._props.select(false); MarqueeAnnotator.clearAnnotations(this._savedAnnotations); this.isAnnotating = true; @@ -394,8 +392,26 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { } }; + /** + * Create a flashcard pile based on the selected text of a pdf. + */ + gptPDFFlashcards = async () => { + const queryText = this._selectionText; + runInAction(() => (this._loading = true)); + try { + const res = await gptAPICall(queryText, GPTCallType.FLASHCARD); + + AnchorMenu.Instance.transferToFlashcard(res || 'Something went wrong', NumCast(this._props.layoutDoc['x']), NumCast(this._props.layoutDoc['y'])); + this._selectionText = ''; + } catch (err) { + console.error(err); + } + runInAction(() => (this._loading = false)); + }; + @action finishMarquee = (/* x?: number, y?: number */) => { + AnchorMenu.Instance.makeLabels = unimplementedFunction; this._getAnchor = AnchorMenu.Instance?.GetAnchor; this.isAnnotating = false; this._marqueeref.current?.onTerminateSelection(); @@ -411,8 +427,10 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { document.removeEventListener('pointerup', this.onSelectEnd); const sel = window.getSelection(); + if (sel) { AnchorMenu.Instance.setSelectedText(sel.toString()); + AnchorMenu.Instance.setLocation(NumCast(this._props.layoutDoc['x']), NumCast(this._props.layoutDoc['y'])); } if (sel?.type === 'Range') { @@ -420,10 +438,12 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { AnchorMenu.Instance.jumpTo(e.clientX, e.clientY); } - GPTPopup.Instance.setSidebarId('data_sidebar'); + GPTPopup.Instance.setSidebarFieldKey('data_sidebar'); GPTPopup.Instance.addDoc = this._props.sidebarAddDoc; // allows for creating collection AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument; + AnchorMenu.Instance.gptFlashcards = this.gptPDFFlashcards; + AnchorMenu.Instance.makeLabels = unimplementedFunction; AnchorMenu.Instance.AddDrawingAnnotation = this.addDrawingAnnotation; }; @@ -459,6 +479,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { this._mainCont.current!.style.transform = ''; } this._selectionContent = selRange.cloneContents(); + this._selectionText = this._selectionContent?.textContent || ''; // clear selection @@ -511,7 +532,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { } getScrollHeight = () => this._scrollHeight; - scrollXf = () => this._props.ScreenToLocalTransform().translate(0, this._mainCont.current ? NumCast(this._props.layoutDoc._layout_scrollTop) : 0); + scrollXf = () => this._props.ScreenToLocalTransform().translate(0, this._mainCont.current ? NumCast(this._props.layoutDoc._layout_scrollTop) / 1.333 : 0); overlayTransform = () => this.scrollXf().scale(1 / NumCast(this._props.layoutDoc._freeform_scale, 1)); panelWidth = () => this._props.PanelWidth() / (this._props.NativeDimScaling?.() || 1); panelHeight = () => this._props.PanelHeight() / (this._props.NativeDimScaling?.() || 1); @@ -532,7 +553,8 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { className="pdfViewerDash-overlay" style={{ mixBlendMode, - display: display, + display, + transform: `scale(${Pdfjs.PixelsPerInch.PDF_TO_CSS_UNITS})`, pointerEvents: Doc.ActiveTool !== InkTool.None ? 'all' : undefined, }}> <CollectionFreeFormView @@ -578,6 +600,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { } savedAnnotations = () => this._savedAnnotations; addDocumentWrapper = (doc: Doc | Doc[]) => this._props.addDocument!(doc); + screenToMarqueeXf = () => this.props.pdfBox.DocumentView?.()?.screenToContentsTransform().scale(Pdfjs.PixelsPerInch.PDF_TO_CSS_UNITS) ?? Transform.Identity(); render() { TraceMobx(); return ( @@ -597,17 +620,18 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { {this.annotationLayer} {this.overlayLayer} {this._showWaiting ? <img alt="" className="pdfViewerDash-waiting" src="/assets/loading.gif" /> : null} - {!this._mainCont.current || !this._annotationLayer.current ? null : ( + {!this._mainCont.current || !this._annotationLayer.current || !this.props.pdfBox.DocumentView ? null : ( <MarqueeAnnotator ref={this._marqueeref} Document={this._props.Document} getPageFromScroll={this.getPageFromScroll} anchorMenuClick={this._props.anchorMenuClick} scrollTop={0} - isNativeScaled + annotationLayerScaling={() => Pdfjs.PixelsPerInch.PDF_TO_CSS_UNITS} annotationLayerScrollTop={NumCast(this._props.Document._layout_scrollTop)} addDocument={this.addDocumentWrapper} - docView={this._props.pdfBox.DocumentView!} + docView={this.props.pdfBox.DocumentView} + screenTransform={this.screenToMarqueeXf} finishMarquee={this.finishMarquee} savedAnnotations={this.savedAnnotations} selectionText={this.selectionText} @@ -617,6 +641,11 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { /> )} </div> + {this._loading ? ( + <div className="loading-spinner" style={{ position: 'absolute' }}> + <ReactLoading type="spin" height={80} width={80} color={'blue'} /> + </div> + ) : null} </div> ); } |
