aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/pdf
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/pdf')
-rw-r--r--src/client/views/pdf/GPTPopup/GPTPopup.tsx231
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>
);