From 737efc3e6cada537536c7bc5e46f5b57970da276 Mon Sep 17 00:00:00 2001 From: bobzel Date: Fri, 11 Oct 2024 22:01:50 -0400 Subject: Lots of cleanup in comparisonBox to get rid of duplicated code and siimplify long functions. Utility functions were added to ComparisonBox to simplify menu functions, and to FormattedTextBox to simplify creating centered text views. --- src/client/apis/gpt/GPT.ts | 8 +- src/client/documents/Documents.ts | 1 + src/client/views/nodes/ComparisonBox.tsx | 526 ++++++++++----------- .../views/nodes/formattedText/FormattedTextBox.tsx | 20 + src/client/views/pdf/AnchorMenu.tsx | 34 +- src/fields/util.ts | 1 - 6 files changed, 287 insertions(+), 303 deletions(-) (limited to 'src') diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index 66c49abc7..8a2c91269 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -5,8 +5,8 @@ enum GPTCallType { SUMMARY = 'summary', COMPLETION = 'completion', EDIT = 'edit', - CHATCARD = 'chatcard', - FLASHCARD = 'flashcard', + CHATCARD = 'chatcard', // a single flashcard style response to a question + FLASHCARD = 'flashcard', // a set of flashcard qustion/answer responses to a topic QUIZ = 'quiz', SORT = 'sort', DESCRIBE = 'describe', @@ -38,7 +38,6 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { // newest model: gpt-4 summary: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Summarize the text given in simpler terms.' }, edit: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' }, - flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' }, stack: { model: 'gpt-4o', maxTokens: 2048, @@ -66,6 +65,7 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { prompt: "The user is going to give you a list of descriptions. Each one is separated by `======` on either side. Descriptions will vary in length, so make sure to only separate when you see `======`. Sort them by the user's specifications. Make sure each description is only in the list once. Each item should be separated by `======`. Immediately afterward, surrounded by `------` on BOTH SIDES, provide some insight into your reasoning for the way you sorted (and mention nothing about the formatting details given in this description). It is VERY important that you format it exactly as described, ensuring the proper number of `=` and `-` (6 of each) and NO commas", }, describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' }, + flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' }, chatcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Answer the following question as a short flashcard response. Do not include a label.' }, quiz: { model: 'gpt-4-turbo', @@ -127,7 +127,7 @@ let lastResp = ''; */ const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: string, dontCache?: boolean) => { const inputText = [GPTCallType.SUMMARY, GPTCallType.FLASHCARD, GPTCallType.QUIZ, GPTCallType.STACK].includes(callType) ? inputTextIn + '.' : inputTextIn; - const opts: GPTCallOpts = callTypeMap[callType]; + const opts = callTypeMap[callType]; if (lastCall === inputText && dontCache !== true) return lastResp; try { lastCall = inputText; diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 0d7e0b20e..f71b9f879 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -305,6 +305,7 @@ export class DocumentOptions { _text_fontFamily?: string; _text_fontWeight?: string; text_align?: STRt = new StrInfo('horizontal text alignment default'); + text_placeholder?: BOOLt = new BoolInfo('makes the text act like a placeholder and automatically select when the text box is selected'); fontSize?: string; _pivotField?: string; // field key used to determine headings for sections in stacking, masonry, pivot views diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx index 0582bc996..80ef126dc 100644 --- a/src/client/views/nodes/ComparisonBox.tsx +++ b/src/client/views/nodes/ComparisonBox.tsx @@ -9,7 +9,6 @@ import { imageUrlToBase64, returnFalse, returnNone, returnTrue, returnZero, setu import { emptyFunction } from '../../../Utils'; import { Doc, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; -import { Id } from '../../../fields/FieldSymbols'; import { RichTextField } from '../../../fields/RichTextField'; import { BoolCast, DocCast, NumCast, RTFCast, StrCast, toList } from '../../../fields/Types'; import { nullAudio } from '../../../fields/URLField'; @@ -26,21 +25,22 @@ import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { PinDocView, PinProps } from '../PinFuncs'; import { StyleProp } from '../StyleProp'; import { flashcardRevealOp, practiceMode } from '../collections/FlashcardPracticeUI'; +import { CollectionFreeFormView } from '../collections/collectionFreeForm'; import '../pdf/GPTPopup/GPTPopup.scss'; import './ComparisonBox.scss'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FormattedTextBox } from './formattedText/FormattedTextBox'; -import { CollectionFreeFormView } from '../collections/collectionFreeForm'; const API_URL = 'https://api.unsplash.com/search/photos'; /** - * This view serves two distinct functions depending on the metadata field layout_isFlashcard + * This view serves three distinct functions depending on the metadata field layout_isFlashcard * 1) it provides a before/after animated sliding transition between two Docs * 2) it provides a question/answer switch between two Docs (flashcard) + * 3) it provides a quiz view that displays a question and a user answer that can be "scored" by GPT * - * In either case, the two docs are stored in the _front and _back fields + * In each case, the two docs are stored in the _front and _back fields * * In the case of the flashcard, there is an icon that allows the user to choose between a * hover and a flip action to switch between cards. The transition is stored in the 'revealOp' field. @@ -49,6 +49,9 @@ const API_URL = 'https://api.unsplash.com/search/photos'; * One option is to allow the user to enter a topic and, by clicking on the flashcard stack button, * convert the comparision box into a stack of comparison boxes filled in by GPT about the topic. * + * Quiz mode is activated when the parent collection has its 'quiz' field set when it renders a flashcard. + * NOTE: this should probably be changed to passing down a prop to the flashcard telling it to render as a quiz. + * */ @observer @@ -56,6 +59,54 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent() public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ComparisonBox, fieldKey); } + /** + * Creates a flashcard (or fills in flashcard data to a specified Doc) from a control string containing a question and answer + * @param tuple string containing Question:, Answer: and optionally a Keyword: + * @param useDoc doc to fill in instead of creating a Doc + * @returns the resulting flashcard Doc + */ + public static createFlashcard(tuple: string, frontKey: string, backKey: string, useDoc?: Doc) { + const [ktoken, atoken] = [ComparisonBox.ktoken, ComparisonBox.atoken]; + const newDoc = useDoc ?? Docs.Create.ComparisonDocument('', { _layout_isFlashcard: true, _width: 300, _height: 300 }); + const question = (tuple.includes(ktoken) ? tuple.split(ktoken)[0] : tuple).split(atoken)[0]; + const rest = tuple.replace(question, ''); + // prettier-ignore + const answer = rest.startsWith(ktoken) ? // if keyword comes first, + tuple.includes(atoken) ? tuple.split(atoken)[1] : "" : //if tuple includes answer, split at answer and take what's left, otherwise there's no answer + rest.includes(ktoken) ? // otherwise if keyword is present it must come after answer, + rest.split(ktoken)[0].split(atoken)[1] : // split at keyword and take what comes first and split that at answer and take what's left + rest.replace(atoken,""); // finally if there's no keyword, just get rid of answer token and take what's left + const keyword = rest.replace(atoken, '').replace(answer, '').replace(ktoken, '').trim(); + const fillInFlashcard = (img?: Doc) => { + newDoc[DocData][frontKey] = FormattedTextBox.centeredTextCreator('question', question, img); + newDoc[DocData][backKey] = FormattedTextBox.centeredTextCreator('answer', answer); + return newDoc; + }; + return keyword && keyword.toLowerCase() !== 'none' ? ComparisonBox.fetchImages(keyword).then(img => fillInFlashcard(img)) : fillInFlashcard(); + } + + /** + * Create a carousel of flashcards from a GPT response string where questions and answers are given in a format loosely defined by: + * Question: ... Answer: ... Keyword: ... + * Note that Keyword or Answer may not be present, or their orders may be reversed. + */ + public static createFlashcardDeck(text: string, width: number, height: number, front: string, back: string) { + return Promise.all( + text + .split(ComparisonBox.qtoken) + .filter(t => t) + .map(tuple => ComparisonBox.createFlashcard(tuple, front, back)) + ).then(docs => { + return Docs.Create.CarouselDocument(docs, { + _width: width, + _height: height, + _layout_fitWidth: false, + _layout_autoHeight: true, + _xMargin: 5, + _yMargin: 5, + }); + }); + } private SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; static qtoken = 'Question: '; @@ -118,9 +169,12 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent() return undefined; }, 'internal drop'); + @computed get isQuizMode() { return DocCast(this.Document.embedContainer)?.practiceMode === practiceMode.QUIZ; } // prettier-ignore @computed get isFlashcard() { return BoolCast(this.Document.layout_isFlashcard); } // prettier-ignore @computed get frontKey() { return this._props.fieldKey + '_front'; } // prettier-ignore @computed get backKey() { return this._props.fieldKey + '_back'; } // prettier-ignore + @computed get frontText() { return RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text; } // prettier-ignore + @computed get backText() { return RTFCast(DocCast(this.dataDoc[this.backKey]).text)?.Text; } // prettier-ignore @computed get revealOpKey() { return `_${this._props.fieldKey}_revealOp`; } // prettier-ignore @computed get clipHeightKey() { return `_${this._props.fieldKey}_clipHeight`; } // prettier-ignore @computed get clipWidthKey() { return `_${this._props.fieldKey}_clipWidth`; } // prettier-ignore @@ -177,9 +231,19 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent() )} - {!this._props.isSelected() || this._renderSide === this.backKey || CollectionFreeFormView.from(this.DocumentView?.()) ? null : ( + {!this._props.isSelected() || this._renderSide === this.backKey || !CollectionFreeFormView.from(this.DocumentView?.()) || (this.dataDoc[this.backKey] && !DocCast(this.dataDoc[this.backKey])?.text_placeholder) ? null : ( Create new flashcard stack based on text}> -
this.askGPT(GPTCallType.STACK).then(this.createFlashcardDeck)}> +
+ this.askGPT(GPTCallType.STACK).then(async text => { + const newCol = await ComparisonBox.createFlashcardDeck(text, NumCast(this.layoutDoc._width, 250) + 50, NumCast(this.layoutDoc._height, 200), this.frontKey, this.backKey); + newCol.x = NumCast(this.layoutDoc.x); + newCol.y = NumCast(this.layoutDoc.y); + this._props.DocumentView?.()._props.addDocument?.(newCol); + this._props.removeDocument?.(this.Document); + }) + }>
@@ -397,124 +461,51 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent() axios .post( 'http://localhost:105/youtube/', // - { file: this.getYouTubeVideoId(StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text)) }, + { file: this.getYouTubeVideoId(this.frontText) }, { headers: { 'Content-Type': 'application/json' } } ) .then(response => response.data.transcription); - /** - * Creates a flashcard (or fills in flashcard data to a specified Doc) from a control string containing a question and answer - * @param tuple string containing Question:, Answer: and optionally a Keyword: - * @param useDoc doc to fill in instead of creating a Doc - * @returns the resulting flashcard Doc - */ - createFlashcard = (tuple: string, useDoc?: Doc) => { - const [ktoken, atoken] = [ComparisonBox.ktoken, ComparisonBox.atoken]; - const newDoc = useDoc ?? Docs.Create.ComparisonDocument('', { _layout_isFlashcard: true, _width: 300, _height: 300 }); - const question = (tuple.includes(ktoken) ? tuple.split(ktoken)[0] : tuple).split(atoken)[0]; - const rest = tuple.replace(question, ''); - // prettier-ignore - const answer = rest.startsWith(ktoken) ? // if keyword comes first, - tuple.includes(atoken) ? tuple.split(atoken)[1] : "" : //if tuple includes answer, split at answer and take what's left, otherwise there's no answer - rest.includes(ktoken) ? // otherwise if keyword is present it must come after answer, - rest.split(ktoken)[0].split(atoken)[1] : // split at keyword and take what comes first and split that at answer and take what's left - rest.replace(atoken,""); // finally if there's no keyword, just get rid of answer token and take what's left - const keyword = rest.replace(atoken, '').replace(answer, '').replace(ktoken, '').trim(); - const fillInFlashcard = (img?: Doc) => { - newDoc[DocData][this.frontKey] = this.textCreator('question', question, img); - newDoc[DocData][this.backKey] = this.textCreator('answer', answer); - return newDoc; - }; - return keyword && keyword.toLowerCase() !== 'none' ? this.fetchImages(keyword).then(img => fillInFlashcard(img)) : fillInFlashcard(); - }; - - /** - * Create a carousel of flashcards from a GPT response string where questions and answers are given in a format loosely defined by: - * Question: ... Answer: ... Keyword: ... - * Note that Keyword or Answer may not be present, or their orders may be reversed. - */ - createFlashcardDeck = (text: string) => { - Promise.all( - text - .split(ComparisonBox.qtoken) - .filter(t => t) - .map(tuple => this.createFlashcard(tuple)) - ).then(docs => { - const newCol = Docs.Create.CarouselDocument(docs, { - _width: NumCast(this.layoutDoc._width, 250) + 50, - _height: NumCast(this.layoutDoc._height, 200) + 50, - _layout_fitWidth: false, - _layout_autoHeight: true, - _xMargin: 5, - _yMargin: 5, - x: NumCast(this.layoutDoc.x), - y: NumCast(this.layoutDoc.y), - }); - - this._props.DocumentView?.()._props.addDocument?.(newCol); - this._props.removeDocument?.(this.Document); - }); - }; - /** * Calls GPT for each flashcard type. */ askGPT = async (callType: GPTCallType) => { - const frontText = RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text; - const backText = RTFCast(DocCast(this.dataDoc[this.backKey]).text)?.Text; - const questionText = 'Question: ' + frontText; - const queryText = questionText + (callType == GPTCallType.QUIZ ? ' UserAnswer: ' + this._inputValue + '. ' + ' Rubric: ' + backText : ''); + const questionText = 'Question: ' + this.frontText; + const queryText = questionText + (callType == GPTCallType.QUIZ ? ' UserAnswer: ' + this._inputValue + '. ' + ' Rubric: ' + this.backText : ''); + this.loading = true; - let res = ''; - - if (callType !== GPTCallType.CHATCARD || frontText) { - try { - res = await gptAPICall(queryText, callType); - if (!res) { - console.error('GPT call failed'); - } else - switch (callType) { - case GPTCallType.CHATCARD: - DocCast(this.dataDoc[this.backKey])[DocData].text = res; - break; - case GPTCallType.QUIZ: - runInAction(() => { - this._renderSide = this.backKey; - this._outputValue = res.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric'); - }); - break; - case GPTCallType.FLASHCARD: - default: - } - } catch (err) { - console.error('GPT call failed', err); - } - } + const res = !this.frontText + ? '' + : await gptAPICall(queryText, callType).then( + action(resp => { + switch (resp && callType) { + case GPTCallType.CHATCARD: + DocCast(this.dataDoc[this.backKey])[DocData].text = resp; + break; + case GPTCallType.QUIZ: + this._renderSide = this.backKey; + this._outputValue = resp.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric'); + break; + case GPTCallType.FLASHCARD: + default: + } + return resp; + }) + ); this.loading = false; + if (!res) console.error('GPT call failed'); return res; }; layoutWidth = () => NumCast(this.layoutDoc.width, 200); layoutHeight = () => NumCast(this.layoutDoc.height, 200); - findImageTags = async () => { - const c = this.DocumentView?.().ContentDiv?.getElementsByTagName('img'); - if (c?.length === 0) this.askGPT(GPTCallType.CHATCARD); - if (c) { - this.loading = true; - for (const i of c) { - if (i.className !== 'ProseMirror-separator') this.getImageDesc(i.src); - } - this.loading = false; - } - }; - /** * Ask GPT for advice on how to improve speech by comparing the phonetic transcription of * a users audio recording with the phonetic transcription of their intended sentence. * @param phonemes */ askGPTPhonemes = async (phonemes: string) => { - const sentence = StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text); + const sentence = this.frontText; const phon6 = 'huː ɑɹ juː tədeɪ'; const phon4 = 'kamo estas hɔi'; const promptEng = @@ -567,24 +558,20 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent() * @param selection * @returns Image Document */ - fetchImages = async (selection: string) => { + public static async fetchImages(selection: string) { try { const { data } = await axios.get(`${API_URL}?query=${selection}&page=1&per_page=${1}&client_id=Q4zruu6k6lum2kExiGhLNBJIgXDxD6NNj0SRHH_XXU0`); const imageSnapshot = Docs.Create.ImageDocument(data.results[0].urls.small, { - _nativeWidth: Doc.NativeWidth(this.layoutDoc), - _nativeHeight: Doc.NativeHeight(this.layoutDoc), - x: NumCast(this.layoutDoc.x), - y: NumCast(this.layoutDoc.y), onClick: FollowLinkScript(), _width: 150, _height: 150, - title: '--snapshot' + NumCast(this.layoutDoc._layout_currentTimecode) + ' image-', + title: selection, }); return imageSnapshot; } catch (error) { console.log(error); } - }; + } getImageDesc = async (u: string) => { try { @@ -610,9 +597,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent() flashcardContextMenu = () => { const appearance = ContextMenu.Instance.findByDescription('Appearance...'); const appearanceItems = appearance?.subitems ?? []; - if (this.Document._layout_isFlashcard) { - appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(GPTCallType.CHATCARD), icon: 'id-card' }); - } + appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(GPTCallType.CHATCARD), icon: 'id-card' }); !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' }); }; @@ -641,181 +626,174 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent() } return layoutTemplateString; }; - textCreator = (title: string, text: string, img?: Doc) => { - const newDoc = Docs.Create.TextDocument(RichTextField.textToRtf(text, img?.[Id]), { - title, // - _layout_autoHeight: true, - _layout_centered: true, - text_align: 'center', - _layout_fitWidth: true, - }); - return newDoc; - }; childActiveFunc = () => this._childActive; contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1); - render() { - const clearButton = (which: string) => ( - remove
}> -
this.closeDown(e, which)} // prevent triggering slider movement in registerSliding - > - -
-
- ); - const displayDoc = (whichSlot: string) => { - const whichDoc = DocCast(this.dataDoc[whichSlot]); - const targetDoc = DocCast(whichDoc?.annotationOn, whichDoc); - const layoutString = targetDoc ? '' : this.testForTextFields(whichSlot); - - return targetDoc || layoutString ? ( - <> - - {!this.isFlashcard ? clearButton(whichSlot) : null} - - ) : ( -
- -
- ); - }; - const displayBox = (which: string, cover: number) => ( + + clearButton = (which: string) => ( + remove}>
{ - this.registerSliding(e, cover); - this.isFlashcard && this.activateContent(); - }} - ref={ele => this.createDropTarget(ele, which)}> - {!this._isEmpty ? displayDoc(which) : null} + ref={this._closeRef} + className={`clear-button ${which}`} + onPointerDown={e => this.closeDown(e, which)} // prevent triggering slider movement in registerSliding + > + +
+
+ ); + displayDoc = (whichSlot: string) => { + const whichDoc = DocCast(this.dataDoc[whichSlot]); + const targetDoc = DocCast(whichDoc?.annotationOn, whichDoc); + const layoutString = targetDoc ? '' : this.testForTextFields(whichSlot); + + return targetDoc || layoutString ? ( + <> + + {!this.isFlashcard ? this.clearButton(whichSlot) : null} + + ) : ( +
+
); + }; - if (this.isFlashcard) { - if (this.dataDoc.data) { - if (!this.dataDoc[this.backKey] || !this.dataDoc[this.frontKey]) this.createFlashcard(StrCast(this.dataDoc.data), this.Document); - } else { - // add text box to each side when comparison box is first created - if (!this.dataDoc[this.backKey] && !this._isEmpty) { - const answer = this.textCreator('answer', 'answer here'); - this.dataDoc[this.backKey] = answer; - answer[DocData].text_placeholder = true; - } - - if (!this.dataDoc[this.frontKey] && !this._isEmpty) { - const question = this.textCreator('question', 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards'); - this.dataDoc[this.frontKey] = question; - question[DocData].text_placeholder = true; - } - } - - if (DocCast(this.Document.embedContainer)?.practiceMode === practiceMode.QUIZ) { - const text = StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text); - return ( -
-

{text}

-

Return to all flashcards and add text to both sides.

-
-