aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/client/apis/gpt/GPT.ts87
-rw-r--r--src/client/apis/gpt/Summarization.ts48
-rw-r--r--src/client/views/nodes/WebBox.tsx2
-rw-r--r--src/client/views/nodes/formattedText/FormattedTextBox.tsx31
-rw-r--r--src/client/views/pdf/AnchorMenu.tsx147
-rw-r--r--src/client/views/pdf/GPTPopup.tsx131
-rw-r--r--src/client/views/pdf/GPTPopup/GPTPopup.scss (renamed from src/client/views/pdf/GPTPopup.scss)15
-rw-r--r--src/client/views/pdf/GPTPopup/GPTPopup.tsx186
-rw-r--r--src/client/views/pdf/PDFViewer.tsx2
9 files changed, 427 insertions, 222 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
new file mode 100644
index 000000000..4b3960902
--- /dev/null
+++ b/src/client/apis/gpt/GPT.ts
@@ -0,0 +1,87 @@
+import { Configuration, OpenAIApi } from 'openai';
+
+enum GPTCallType {
+ SUMMARY = 'summary',
+ COMPLETION = 'completion',
+ EDIT = 'edit',
+}
+
+type GPTCallOpts = {
+ model: string;
+ maxTokens: number;
+ temp: number;
+ prompt: string;
+};
+
+const callTypeMap: { [type: string]: GPTCallOpts } = {
+ summary: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Summarize this text briefly: ' },
+ edit: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Reword this: ' },
+ completion: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: '' },
+};
+
+/**
+ * Calls the OpenAI API.
+ *
+ * @param inputText Text to process
+ * @returns AI Output
+ */
+const gptAPICall = async (inputText: string, callType: GPTCallType) => {
+ if (callType === GPTCallType.SUMMARY) inputText += '.';
+ const opts: GPTCallOpts = callTypeMap[callType];
+ try {
+ const configuration = new Configuration({
+ apiKey: process.env.OPENAI_KEY,
+ });
+ const openai = new OpenAIApi(configuration);
+ const response = await openai.createCompletion({
+ model: opts.model,
+ max_tokens: opts.maxTokens,
+ temperature: opts.temp,
+ prompt: `${opts.prompt}${inputText}`,
+ });
+ console.log(response.data.choices[0]);
+ return response.data.choices[0].text;
+ } catch (err) {
+ console.log(err);
+ return 'Error connecting with API.';
+ }
+};
+
+const gptImageCall = async (prompt: string) => {
+ try {
+ const configuration = new Configuration({
+ apiKey: process.env.OPENAI_KEY,
+ });
+ const openai = new OpenAIApi(configuration);
+ const response = await openai.createImage({
+ prompt: prompt,
+ n: 1,
+ size: '1024x1024',
+ });
+ return response.data.data[0].url;
+ } catch (err) {
+ console.error(err);
+ return;
+ }
+};
+
+// const gptEditCall = async (selectedText: string, fullText: string) => {
+// try {
+// const configuration = new Configuration({
+// apiKey: process.env.OPENAI_KEY,
+// });
+// const openai = new OpenAIApi(configuration);
+// const response = await openai.createCompletion({
+// model: 'text-davinci-003',
+// max_tokens: 256,
+// temperature: 0.1,
+// prompt: `Replace the phrase ${selectedText} inside of ${fullText}.`,
+// });
+// return response.data.choices[0].text.trim();
+// } catch (err) {
+// console.log(err);
+// return 'Error connecting with API.';
+// }
+// };
+
+export { gptAPICall, gptImageCall, GPTCallType };
diff --git a/src/client/apis/gpt/Summarization.ts b/src/client/apis/gpt/Summarization.ts
deleted file mode 100644
index 0d9b5dfcd..000000000
--- a/src/client/apis/gpt/Summarization.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Configuration, OpenAIApi } from 'openai';
-
-enum GPTCallType {
- SUMMARY = 'summary',
- COMPLETION = 'completion',
-}
-
-type GPTCallOpts = {
- model: string;
- maxTokens: number;
- temp: number;
- prompt: string;
-};
-
-const callTypeMap: { [type: string]: GPTCallOpts } = {
- summary: { model: 'text-davinci-003', maxTokens: 100, temp: 0.5, prompt: 'Summarize this text: ' },
- completion: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: '' },
-};
-
-/**
- * Calls the OpenAI API.
- *
- * @param inputText Text to process
- * @returns AI Output
- */
-const gptAPICall = async (inputText: string, callType: GPTCallType) => {
- if (callType === GPTCallType.SUMMARY) inputText += '.';
- const opts: GPTCallOpts = callTypeMap[callType];
- try {
- const configuration = new Configuration({
- apiKey: process.env.OPENAI_KEY,
- });
- const openai = new OpenAIApi(configuration);
- const response = await openai.createCompletion({
- model: opts.model,
- max_tokens: opts.maxTokens,
- temperature: opts.temp,
- prompt: `${opts.prompt}${inputText}`,
- });
- return response.data.choices[0].text;
- } catch (err) {
- console.log(err);
- return 'Error connecting with API.';
- }
-};
-
-
-export { gptAPICall, GPTCallType};
diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx
index e5ef85b5a..abde5a9ea 100644
--- a/src/client/views/nodes/WebBox.tsx
+++ b/src/client/views/nodes/WebBox.tsx
@@ -36,7 +36,7 @@ import { PinProps, PresBox } from './trails';
import './WebBox.scss';
import React = require('react');
import { DragManager } from '../../util/DragManager';
-import { GPTPopup } from '../pdf/GPTPopup';
+import { GPTPopup } from '../pdf/GPTPopup/GPTPopup';
const { CreateImage } = require('./WebBoxRenderer');
const _global = (window /* browser */ || global) /* node */ as any;
const htmlToText = require('html-to-text');
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 4ad1f73b0..3eae47f49 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -23,7 +23,7 @@ import { BoolCast, Cast, DocCast, FieldValue, NumCast, ScriptCast, StrCast } fro
import { GetEffectiveAcl, TraceMobx } from '../../../../fields/util';
import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, OmitKeys, returnFalse, returnZero, setupMoveUpEvents, smoothScroll, unimplementedFunction, Utils } from '../../../../Utils';
import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils';
-import { gptAPICall, GPTCallType } from '../../../apis/gpt/Summarization';
+import { gptAPICall, GPTCallType, gptImageCall } from '../../../apis/gpt/GPT';
import { DocServer } from '../../../DocServer';
import { Docs, DocUtils } from '../../../documents/Documents';
import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
@@ -891,26 +891,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
generateImage = async () => {
console.log('Generate image from text: ', (this.dataDoc.text as RichTextField)?.Text);
try {
- const configuration = new Configuration({
- apiKey: process.env.OPENAI_KEY,
- });
- const openai = new OpenAIApi(configuration);
- const response = await openai.createImage({
- prompt: (this.dataDoc.text as RichTextField)?.Text,
- n: 1,
- size: '1024x1024',
- });
- let image_url = response.data.data[0].url;
- console.log(image_url);
+ let image_url = await gptImageCall((this.dataDoc.text as RichTextField)?.Text);
if (image_url) {
const [{ accessPaths }] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_url] });
const source = Utils.prepend(accessPaths.agnostic.client);
const newDoc = Docs.Create.ImageDocument(source, {
- x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10,
- y: NumCast(this.rootDoc.y),
- _height: 200,
- _width: 200,
- })
+ x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10,
+ y: NumCast(this.rootDoc.y),
+ _height: 200,
+ _width: 200,
+ });
if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) {
newDoc.overlayX = this.rootDoc.x;
newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height);
@@ -919,7 +909,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
this.props.addDocument?.(newDoc);
}
// Create link between prompt and image
- DocUtils.MakeLink({doc: this.rootDoc}, {doc: newDoc}, "Image Prompt");
+ DocUtils.MakeLink({ doc: this.rootDoc }, { doc: newDoc }, 'Image Prompt');
}
} catch (err) {
console.log(err);
@@ -1189,6 +1179,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this.props);
this.autoLink();
}
+ // Accessing editor and text doc for gpt assisted text edits
+ if (this._editorView && selected) {
+ AnchorMenu.Instance?.setEditorView(this._editorView);
+ AnchorMenu.Instance?.setTextDoc(this.dataDoc);
+ }
}),
{ fireImmediately: true }
);
diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx
index 1b30e1f68..b66f294f4 100644
--- a/src/client/views/pdf/AnchorMenu.tsx
+++ b/src/client/views/pdf/AnchorMenu.tsx
@@ -10,10 +10,11 @@ import { SelectionManager } from '../../util/SelectionManager';
import { AntimodeMenu, AntimodeMenuProps } from '../AntimodeMenu';
import { LinkPopup } from '../linking/LinkPopup';
import { ButtonDropdown } from '../nodes/formattedText/RichTextMenu';
-import { gptAPICall, GPTCallType } from '../../apis/gpt/Summarization';
-import { GPTPopup } from './GPTPopup';
-import './AnchorMenu.scss';
+import { gptAPICall, GPTCallType } from '../../apis/gpt/GPT';
+import { GPTPopup, GPTPopupMode } from './GPTPopup/GPTPopup';
import { LightboxView } from '../LightboxView';
+import { EditorView } from 'prosemirror-view';
+import './AnchorMenu.scss';
@observer
export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
@@ -46,27 +47,55 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
@observable public Status: 'marquee' | 'annotation' | '' = '';
// GPT additions
- @observable private summarizedText: string = '';
+ @observable private GPTpopupText: string = '';
@observable private loadingSummary: boolean = false;
@observable private showGPTPopup: boolean = false;
+ @observable private GPTMode: GPTPopupMode = GPTPopupMode.SUMMARY;
+ @observable private selectedText: string = '';
+ @observable private editorView?: EditorView;
+ @observable private textDoc?: Doc;
+ @observable private highlightRange: number[] | undefined;
+ private selectionRange: number[] | undefined;
+
@action
setGPTPopupVis = (vis: boolean) => {
this.showGPTPopup = vis;
};
@action
- setSummarizedText = (txt: string) => {
- this.summarizedText = txt;
+ setGPTMode = (mode: GPTPopupMode) => {
+ this.GPTMode = mode;
+ };
+
+ @action
+ setGPTPopupText = (txt: string) => {
+ this.GPTpopupText = txt;
};
+
@action
setLoading = (loading: boolean) => {
this.loadingSummary = loading;
};
- private selectedText: string = '';
+ @action
+ setHighlightRange(r: number[] | undefined) {
+ this.highlightRange = r;
+ }
+
+ @action
public setSelectedText = (txt: string) => {
this.selectedText = txt;
};
+ @action
+ public setEditorView = (editor: EditorView) => {
+ this.editorView = editor;
+ };
+
+ @action
+ public setTextDoc = (textDoc: Doc) => {
+ this.textDoc = textDoc;
+ };
+
public onMakeAnchor: () => Opt<Doc> = () => undefined; // Method to get anchor from text search
public OnCrop: (e: PointerEvent) => void = unimplementedFunction;
@@ -104,7 +133,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
if (!opacity) {
this._showLinkPopup = false;
this.setGPTPopupVis(false);
- this.setSummarizedText('');
+ this.setGPTPopupText('');
}
},
{ fireImmediately: true }
@@ -114,20 +143,20 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
selected => {
this._showLinkPopup = false;
this.setGPTPopupVis(false);
- this.setSummarizedText('');
+ this.setGPTPopupText('');
AnchorMenu.Instance.fadeOut(true);
}
);
}
/**
- * Returns a mock summary simulating an API call.
+ * Returns a mock api response.
* @returns A Promise that resolves into a string
*/
- mockSummarize = async (): Promise<string> => {
+ mockGPTCall = async (): Promise<string> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
- resolve('Mock summary. This is a summary of the highlighted text.');
+ resolve('test');
}, 1000);
});
};
@@ -137,19 +166,68 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
* @param e pointer down event
*/
gptSummarize = async (e: React.PointerEvent) => {
+ this.setHighlightRange(undefined);
+ this.setGPTPopupVis(true);
+ this.setGPTMode(GPTPopupMode.SUMMARY);
+ this.setLoading(true);
+
+ try {
+ const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY);
+ if (res) {
+ this.setGPTPopupText(res);
+ } else {
+ this.setGPTPopupText('Something went wrong.');
+ }
+ } catch (err) {
+ console.error(err);
+ }
+
+ this.setLoading(false);
+ };
+
+ /**
+ * Makes a GPT call to edit selected text.
+ * @returns nothing
+ */
+ gptEdit = async () => {
+ if (!this.editorView) return;
+ this.setHighlightRange(undefined);
+ const state = this.editorView.state;
+ const sel = state.selection;
+ const fullText = state.doc.textBetween(0, this.editorView.state.doc.content.size, ' \n');
+ const selectedText = state.doc.textBetween(sel.from, sel.to);
+
this.setGPTPopupVis(true);
+ this.setGPTMode(GPTPopupMode.EDIT);
this.setLoading(true);
- const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY);
- // const res = await this.mockSummarize();
- if (res) {
- this.setSummarizedText(res);
- } else {
- this.setSummarizedText('Something went wrong.');
+
+ try {
+ let res = await gptAPICall(selectedText, GPTCallType.EDIT);
+ // let res = await this.mockGPTCall();
+ res = res.trim();
+ const resultText = fullText.slice(0, sel.from - 1) + res + fullText.slice(sel.to);
+
+ if (res) {
+ this.setGPTPopupText(resultText);
+ this.setHighlightRange([sel.from - 1, sel.from - 1 + res.length]);
+ } else {
+ this.setGPTPopupText('Something went wrong.');
+ }
+ } catch (err) {
+ console.error(err);
}
this.setLoading(false);
};
+ /**
+ * Replaces text suggestions from GPT.
+ */
+ replaceText = (replacement: string) => {
+ if (!this.editorView || !this.textDoc) return;
+ this.textDoc.text = replacement;
+ };
+
pointerDown = (e: React.PointerEvent) => {
setupMoveUpEvents(
this,
@@ -250,7 +328,19 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
canSummarize = (): boolean => {
const docs = SelectionManager.Docs();
if (docs.length > 0) {
- return docs[0].type === 'pdf' || docs[0].type === 'web';
+ return docs.some(doc => doc.type === 'pdf' || doc.type === 'web');
+ }
+ return false;
+ };
+
+ /**
+ * Returns whether the selected text can be edited.
+ * @returns Whether the GPT icon for summarization should appear
+ */
+ canEdit = (): boolean => {
+ const docs = SelectionManager.Docs();
+ if (docs.length > 0) {
+ return docs.some(doc => doc.type === 'rtf');
}
return false;
};
@@ -273,7 +363,17 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
</button>
</Tooltip>
)}
- <GPTPopup key="gptpopup" visible={this.showGPTPopup} text={this.summarizedText} loadingSummary={this.loadingSummary} callApi={this.gptSummarize} />
+ <GPTPopup
+ key="gptpopup"
+ visible={this.showGPTPopup}
+ text={this.GPTpopupText}
+ highlightRange={this.highlightRange}
+ loading={this.loadingSummary}
+ callSummaryApi={this.gptSummarize}
+ callEditApi={this.gptEdit}
+ replaceText={this.replaceText}
+ mode={this.GPTMode}
+ />
{AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : (
<Tooltip key="annoaudiotate" title={<div className="dash-tooltip">Click to Record Annotation</div>}>
<button className="antimodeMenu-button annotate" onPointerDown={this.audioDown} style={{ cursor: 'grab' }}>
@@ -281,6 +381,13 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
</button>
</Tooltip>
)}
+ {this.canEdit() && (
+ <Tooltip key="gpttextedit" title={<div className="dash-tooltip">Edit text with AI</div>}>
+ <button className="antimodeMenu-button annotate" onPointerDown={this.gptEdit} style={{ cursor: 'grab' }}>
+ <FontAwesomeIcon icon="pencil-alt" size="lg" />
+ </button>
+ </Tooltip>
+ )}
<Tooltip key="link" title={<div className="dash-tooltip">Find document to link to selected text</div>}>
<button className="antimodeMenu-button link" onPointerDown={this.toggleLinkPopup}>
<FontAwesomeIcon style={{ position: 'absolute', transform: 'scale(1.5)' }} icon={'search'} size="lg" />
diff --git a/src/client/views/pdf/GPTPopup.tsx b/src/client/views/pdf/GPTPopup.tsx
deleted file mode 100644
index ec4fa58dc..000000000
--- a/src/client/views/pdf/GPTPopup.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { action, observable } from 'mobx';
-import { observer } from 'mobx-react';
-import React = require('react');
-import ReactLoading from 'react-loading';
-import Typist from 'react-typist';
-import { Doc } from '../../../fields/Doc';
-import { Docs } from '../../documents/Documents';
-import './GPTPopup.scss';
-
-interface GPTPopupProps {
- visible: boolean;
- text: string;
- loadingSummary: boolean;
- callApi: (e: React.PointerEvent) => Promise<void>;
-}
-
-@observer
-export class GPTPopup extends React.Component<GPTPopupProps> {
- static Instance: GPTPopup;
-
- @observable
- private summaryDone: boolean = false;
- @observable
- private sidebarId: string = '';
- @action
- public setSummaryDone = (done: boolean) => {
- this.summaryDone = done;
- };
- @action
- public setSidebarId = (id: string) => {
- this.sidebarId = id;
- };
-
- public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false;
-
- /**
- * Transfers the summarization text to a sidebar annotation text document.
- */
- private transferToText = () => {
- const newDoc = Docs.Create.TextDocument(this.props.text.trim(), {
- _width: 200,
- _height: 50,
- _fitWidth: true,
- _autoHeight: true,
- });
- this.addDoc(newDoc, this.sidebarId);
- };
-
- constructor(props: GPTPopupProps) {
- super(props);
- GPTPopup.Instance = this;
- }
-
- componentDidUpdate = () => {
- if (this.props.loadingSummary) {
- this.setSummaryDone(false);
- }
- };
-
- render() {
- return (
- <div className="summary-box" style={{ display: this.props.visible ? 'flex' : 'none' }}>
- <div className="summary-heading">
- <label className="summary-text">SUMMARY</label>
- {this.props.loadingSummary && <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} />}
- </div>
- <div className="content-wrapper">
- {!this.props.loadingSummary &&
- (!this.summaryDone ? (
- <Typist
- key={this.props.text}
- avgTypingDelay={15}
- cursor={{ hideWhenDone: true }}
- onTypingDone={action(() => {
- setTimeout(
- action(() => {
- this.summaryDone = true;
- }),
- 500
- );
- })}>
- {this.props.text}
- </Typist>
- ) : (
- this.props.text
- ))}
- </div>
- {!this.props.loadingSummary && (
- <div className="btns-wrapper">
- {this.summaryDone ? (
- <>
- <button className="icon-btn" onPointerDown={e => this.props.callApi(e)}>
- <FontAwesomeIcon icon="redo-alt" size="lg" />
- </button>
- <button
- className="text-btn"
- onClick={e => {
- this.transferToText();
- }}>
- Transfer to Text
- </button>
- </>
- ) : (
- <div className="summarizing">
- <label>Summarizing</label>
- <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} />
- <button
- className="btn-secondary"
- onClick={e => {
- this.setSummaryDone(true);
- }}>
- Stop Animation
- </button>
- </div>
- )}
- </div>
- )}
- {this.summaryDone && (
- <div className="ai-warning">
- <FontAwesomeIcon icon="exclamation-circle" size="sm" style={{ paddingRight: '5px' }} />
- AI generated responses can contain inaccurate or misleading content.{' '}
- <a target="_blank" href="https://www.nytimes.com/2023/02/08/technology/ai-chatbots-disinformation.html">
- Learn More
- </a>
- </div>
- )}
- </div>
- );
- }
-}
diff --git a/src/client/views/pdf/GPTPopup.scss b/src/client/views/pdf/GPTPopup/GPTPopup.scss
index 7b7d2e3f7..50fbe5211 100644
--- a/src/client/views/pdf/GPTPopup.scss
+++ b/src/client/views/pdf/GPTPopup/GPTPopup.scss
@@ -3,19 +3,24 @@ $lighttextgrey: #a3a3a3;
$greyborder: #d3d3d3;
$lightgrey: #ececec;
$button: #5b97ff;
+$highlightedText: #82e0ff;
.summary-box {
display: flex;
flex-direction: column;
+ justify-content: space-between;
background-color: #ffffff;
box-shadow: 0 2px 5px #7474748d;
color: $textgrey;
- position: absolute;
- bottom: 40px;
+ position: fixed;
+ bottom: 5px;
+ right: 5px;
width: 250px;
+ min-height: 200px;
border-radius: 15px;
padding: 15px;
- padding-bottom: 0px;
+ padding-bottom: 0;
+ z-index: 999;
.summary-heading {
display: flex;
@@ -99,6 +104,10 @@ $button: #5b97ff;
color: $lighttextgrey;
border-top: 1px solid $greyborder;
}
+
+ .highlighted-text {
+ background-color: $highlightedText;
+ }
}
// Typist CSS
diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
new file mode 100644
index 000000000..91bc0a7ff
--- /dev/null
+++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
@@ -0,0 +1,186 @@
+import React = require('react');
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { action, observable } from 'mobx';
+import { observer } from 'mobx-react';
+import ReactLoading from 'react-loading';
+import Typist from 'react-typist';
+import { Doc } from '../../../../fields/Doc';
+import { Docs } from '../../../documents/Documents';
+import './GPTPopup.scss';
+
+export enum GPTPopupMode {
+ SUMMARY,
+ EDIT,
+}
+
+interface GPTPopupProps {
+ visible: boolean;
+ text: string;
+ loading: boolean;
+ mode: GPTPopupMode;
+ callSummaryApi: (e: React.PointerEvent) => Promise<void>;
+ callEditApi: (e: React.PointerEvent) => Promise<void>;
+ replaceText: (replacement: string) => void;
+ highlightRange?: number[];
+}
+
+@observer
+export class GPTPopup extends React.Component<GPTPopupProps> {
+ static Instance: GPTPopup;
+
+ @observable
+ private done: boolean = false;
+ @observable
+ private sidebarId: string = '';
+
+ @action
+ public setDone = (done: boolean) => {
+ this.done = done;
+ };
+ @action
+ public setSidebarId = (id: string) => {
+ this.sidebarId = id;
+ };
+
+ public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false;
+
+ /**
+ * Transfers the summarization text to a sidebar annotation text document.
+ */
+ private transferToText = () => {
+ const newDoc = Docs.Create.TextDocument(this.props.text.trim(), {
+ _width: 200,
+ _height: 50,
+ _fitWidth: true,
+ _autoHeight: true,
+ });
+ this.addDoc(newDoc, this.sidebarId);
+ };
+
+ constructor(props: GPTPopupProps) {
+ super(props);
+ GPTPopup.Instance = this;
+ }
+
+ componentDidUpdate = () => {
+ if (this.props.loading) {
+ this.setDone(false);
+ }
+ };
+
+ summaryBox = () => (
+ <>
+ <div>
+ {this.heading('SUMMARY')}
+ <div className="content-wrapper">
+ {!this.props.loading &&
+ (!this.done ? (
+ <Typist
+ key={this.props.text}
+ avgTypingDelay={15}
+ cursor={{ hideWhenDone: true }}
+ onTypingDone={() => {
+ setTimeout(() => {
+ this.setDone(true);
+ }, 500);
+ }}>
+ {this.props.text}
+ </Typist>
+ ) : (
+ this.props.text
+ ))}
+ </div>
+ </div>
+ {!this.props.loading && (
+ <div className="btns-wrapper">
+ {this.done ? (
+ <>
+ <button className="icon-btn" onPointerDown={e => this.props.callSummaryApi(e)}>
+ <FontAwesomeIcon icon="redo-alt" size="lg" />
+ </button>
+ <button
+ className="text-btn"
+ onClick={e => {
+ this.transferToText();
+ }}>
+ Transfer to Text
+ </button>
+ </>
+ ) : (
+ <div className="summarizing">
+ <span>Summarizing</span>
+ <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} />
+ <button
+ className="btn-secondary"
+ onClick={e => {
+ this.setDone(true);
+ }}>
+ Stop Animation
+ </button>
+ </div>
+ )}
+ </div>
+ )}
+ </>
+ );
+
+ editBox = () => {
+ const hr = this.props.highlightRange;
+ return (
+ hr && (
+ <>
+ <div>
+ {this.heading('TEXT EDIT SUGGESTIONS')}
+ <div className="content-wrapper">
+ <div>
+ {this.props.text.slice(0, hr[0])} <span className="highlighted-text">{this.props.text.slice(hr[0], hr[1])}</span> {this.props.text.slice(hr[1])}
+ </div>
+ </div>
+ </div>
+ {!this.props.loading && (
+ <div className="btns-wrapper">
+ <>
+ <button className="icon-btn" onPointerDown={e => this.props.callEditApi(e)}>
+ <FontAwesomeIcon icon="redo-alt" size="lg" />
+ </button>
+ <button
+ className="text-btn"
+ onClick={e => {
+ this.props.replaceText(this.props.text);
+ }}>
+ Replace Text
+ </button>
+ </>
+ </div>
+ )}
+ {this.aiWarning()}
+ </>
+ )
+ );
+ };
+
+ aiWarning = () =>
+ this.done ? (
+ <div className="ai-warning">
+ <FontAwesomeIcon icon="exclamation-circle" size="sm" style={{ paddingRight: '5px' }} />
+ AI generated responses can contain inaccurate or misleading content.
+ </div>
+ ) : (
+ <></>
+ );
+
+ heading = (headingText: string) => (
+ <div className="summary-heading">
+ <label className="summary-text">{headingText}</label>
+ {this.props.loading && <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} />}
+ </div>
+ );
+
+ render() {
+ return (
+ <div className="summary-box" style={{ display: this.props.visible ? 'flex' : 'none' }}>
+ {this.props.mode === GPTPopupMode.SUMMARY ? this.summaryBox() : this.editBox()}
+ </div>
+ );
+ }
+}
diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx
index 3f891789a..d17d0e13c 100644
--- a/src/client/views/pdf/PDFViewer.tsx
+++ b/src/client/views/pdf/PDFViewer.tsx
@@ -24,7 +24,7 @@ import { AnchorMenu } from './AnchorMenu';
import { Annotation } from './Annotation';
import './PDFViewer.scss';
import React = require('react');
-import { GPTPopup } from './GPTPopup';
+import { GPTPopup } from './GPTPopup/GPTPopup';
const PDFJSViewer = require('pdfjs-dist/web/pdf_viewer');
const pdfjsLib = require('pdfjs-dist');
const _global = (window /* browser */ || global) /* node */ as any;