diff options
-rw-r--r-- | package-lock.json | 51 | ||||
-rw-r--r-- | package.json | 3 | ||||
-rw-r--r-- | src/client/apis/gpt/Summarization.ts | 48 | ||||
-rw-r--r-- | src/client/views/MainView.tsx | 3 | ||||
-rw-r--r-- | src/client/views/MarqueeAnnotator.tsx | 2 | ||||
-rw-r--r-- | src/client/views/collections/CollectionView.tsx | 2 | ||||
-rw-r--r-- | src/client/views/nodes/ImageBox.tsx | 2 | ||||
-rw-r--r-- | src/client/views/nodes/PDFBox.tsx | 1 | ||||
-rw-r--r-- | src/client/views/nodes/WebBox.tsx | 23 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBox.scss | 4 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBox.tsx | 79 | ||||
-rw-r--r-- | src/client/views/pdf/AnchorMenu.tsx | 86 | ||||
-rw-r--r-- | src/client/views/pdf/GPTPopup.scss | 123 | ||||
-rw-r--r-- | src/client/views/pdf/GPTPopup.tsx | 131 | ||||
-rw-r--r-- | src/client/views/pdf/PDFViewer.tsx | 12 |
15 files changed, 565 insertions, 5 deletions
diff --git a/package-lock.json b/package-lock.json index d1e9ce7fa..4efe167cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2098,6 +2098,15 @@ "@types/react": "*" } }, + "@types/react-typist": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/react-typist/-/react-typist-2.0.3.tgz", + "integrity": "sha512-5eDrikVNJ73qR5XEV+ZNApoYarzYUD5OOPdNwnNY5RtqituZl9haq0OdZUaac2R751QSOe3zDwnqYhBmdFhG7g==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/reactcss": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz", @@ -17433,6 +17442,40 @@ "mimic-fn": "^2.1.0" } }, + "openai": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-3.1.0.tgz", + "integrity": "sha512-v5kKFH5o+8ld+t0arudj833Mgm3GcgBnbyN9946bj6u7bvel4Yg6YFz2A4HLIYDzmMjIo0s6vSG9x73kOwvdCg==", + "requires": { + "axios": "^0.26.0", + "form-data": "^4.0.0" + }, + "dependencies": { + "axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "requires": { + "follow-redirects": "^1.14.8" + } + }, + "follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, "opentype.js": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", @@ -19260,6 +19303,14 @@ } } }, + "react-typist": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/react-typist/-/react-typist-2.0.5.tgz", + "integrity": "sha512-iZCkeqeegO0TlkTMiH2JD1tvMtY9RrXkRylnAI6m8aCVAUUwNzoWTVF7CKLij6THeOMcUDCznLDDvNp55s+YZA==", + "requires": { + "prop-types": "^15.5.10" + } + }, "react-use-measure": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.1.tgz", diff --git a/package.json b/package.json index 00ce356f9..a5fc9ea0c 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@types/react-select": "^3.1.2", "@types/react-table": "^6.8.9", "@types/react-transition-group": "^4.4.5", + "@types/react-typist": "^2.0.3", "@types/request": "^2.48.8", "@types/request-promise": "^4.1.48", "@types/rimraf": "^2.0.5", @@ -240,6 +241,7 @@ "nodemon": "^1.19.4", "normalize.css": "^8.0.1", "npm": "^6.14.18", + "openai": "^3.1.0", "p-limit": "^2.2.0", "passport": "^0.4.0", "passport-google-oauth20": "^2.0.0", @@ -287,6 +289,7 @@ "react-select": "^3.2.0", "react-table": "^6.11.5", "react-transition-group": "^4.4.2", + "react-typist": "^2.0.5", "readline": "^1.3.0", "rehype-raw": "^6.1.1", "remark-gfm": "^3.0.1", diff --git a/src/client/apis/gpt/Summarization.ts b/src/client/apis/gpt/Summarization.ts new file mode 100644 index 000000000..b65736237 --- /dev/null +++ b/src/client/apis/gpt/Summarization.ts @@ -0,0 +1,48 @@ +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: 100, 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/MainView.tsx b/src/client/views/MainView.tsx index e1ba5943d..5f2bef5c8 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -244,6 +244,7 @@ export class MainView extends React.Component { library.add( ...[ + fa.faExclamationCircle, fa.faEdit, fa.faTrash, fa.faTrashAlt, @@ -274,6 +275,7 @@ export class MainView extends React.Component { fa.faHandPointRight, fa.faCompass, fa.faSnowflake, + fa.faStar, fa.faMicrophone, fa.faKeyboard, fa.faQuestion, @@ -321,6 +323,7 @@ export class MainView extends React.Component { fa.faClone, fa.faCloudUploadAlt, fa.faCommentAlt, + fa.faCommentDots, fa.faCompressArrowsAlt, fa.faCut, fa.faEllipsisV, diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx index 30867a774..c02435518 100644 --- a/src/client/views/MarqueeAnnotator.tsx +++ b/src/client/views/MarqueeAnnotator.tsx @@ -199,7 +199,7 @@ export class MarqueeAnnotator extends React.Component<MarqueeAnnotatorProps> { return textRegionAnno; }; @action - highlight = (color: string, isLinkButton: boolean, savedAnnotations?: ObservableMap<number, HTMLDivElement[]>, addAsAnnotation?: boolean) => { + highlight = (color: string, isLinkButton: boolean, savedAnnotations?: ObservableMap<number, HTMLDivElement[]>, addAsAnnotation?: boolean, summarize?: boolean) => { // creates annotation documents for current highlights const effectiveAcl = GetEffectiveAcl(this.props.rootDoc[DataSym]); const annotationDoc = [AclAugment, AclSelfEdit, AclEdit, AclAdmin].includes(effectiveAcl) && this.makeAnnotationDocument(color, isLinkButton, savedAnnotations); diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 316f1e4e9..065ce861a 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -67,6 +67,8 @@ interface CollectionViewProps_ extends FieldViewProps { AddToMap?: (treeViewDoc: Doc, index: number[]) => Doc[]; RemFromMap?: (treeViewDoc: Doc, index: number[]) => Doc[]; hierarchyIndex?: number[]; // hierarchical index of a document up to the rendering root (primarily used for tree views) + // for animation trail in-between selection + isTrailBox?: boolean; } export interface CollectionViewProps extends React.PropsWithChildren<CollectionViewProps_> {} @observer diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index c6d4cb694..60f799463 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -344,7 +344,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @computed get nativeSize() { TraceMobx(); const nativeWidth = NumCast(this.dataDoc[this.fieldKey + '-nativeWidth'], NumCast(this.layoutDoc[this.fieldKey + '-nativeWidth'], 500)); - const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '-nativeHeight'], 1)); + const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '-nativeHeight'], 500)); const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '-nativeOrientation'], 1); return { nativeWidth, nativeHeight, nativeOrientation }; } diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 5f207cc0d..7e0b5c4d3 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -492,6 +492,7 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps }}> <PDFViewer {...this.props} + sidebarAddDoc={this.sidebarAddDocument} rootDoc={this.rootDoc} addDocTab={this.sidebarAddDocTab} layoutDoc={this.layoutDoc} diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index b1acef44a..5f9420a54 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -14,7 +14,6 @@ import { TraceMobx } from '../../../fields/util'; import { emptyFunction, getWordAtPoint, OmitKeys, returnFalse, returnOne, setupMoveUpEvents, smoothScroll, StopEvent, Utils } from '../../../Utils'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; -import { DragManager } from '../../util/DragManager'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { SnappingManager } from '../../util/SnappingManager'; import { undoBatch, UndoManager } from '../../util/UndoManager'; @@ -36,6 +35,9 @@ import { LinkDocPreview } from './LinkDocPreview'; import { PinProps, PresBox } from './trails'; import './WebBox.scss'; import React = require('react'); +import { DragManager } from '../../util/DragManager'; +import { gptAPICall, GPTCallType } from '../../apis/gpt/Summarization'; +import { GPTPopup } from '../pdf/GPTPopup'; const { CreateImage } = require('./WebBoxRenderer'); const _global = (window /* browser */ || global) /* node */ as any; const htmlToText = require('html-to-text'); @@ -58,6 +60,19 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps private _searchRef = React.createRef<HTMLInputElement>(); private _searchString = ''; + // GPT Additions + private _summaryText: string = ''; + setSummaryText = async () => { + try { + const summary = await gptAPICall(this.selectionText(), GPTCallType.SUMMARY); + this._summaryText = `Summary: ${summary}`; + } catch (err) { + console.log(err); + this._summaryText = 'Failed to fetch summary.'; + } + }; + summaryText = () => this._summaryText; + private get _getAnchor() { return AnchorMenu.Instance?.GetAnchor; } @@ -359,8 +374,13 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps const scale = (this.props.NativeDimScaling?.() || 1) * mainContBounds.scale; const sel = this._iframe.contentWindow.getSelection(); if (sel) { + this._selectionText = sel.toString(); + AnchorMenu.Instance.setSelectedText(sel.toString()); this._textAnnotationCreator = () => this.createTextAnnotation(sel, !sel.isCollapsed ? sel.getRangeAt(0) : undefined); AnchorMenu.Instance.jumpTo(e.clientX * scale + mainContBounds.translateX, e.clientY * scale + mainContBounds.translateY - NumCast(this.layoutDoc._scrollTop) * scale); + // Changing which document to add the annotation to (the currently selected WebBox) + GPTPopup.Instance.setSidebarId(`${this.props.fieldKey}-${this._urlHash}-sidebar`); + GPTPopup.Instance.addDoc = this.sidebarAddDocument; } } }; @@ -778,6 +798,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps } addDocumentWrapper = (doc: Doc | Doc[], annotationKey?: string) => { + console.log(annotationKey); (doc instanceof Doc ? [doc] : doc).forEach(doc => (doc.webUrl = this._url)); return this.addDocument(doc, annotationKey); }; diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss index cbe0a465d..fd7fbb333 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -149,6 +149,10 @@ audiotag:hover { } } +.gpt-typing-wrapper { + padding: 10px; +} + // .menuicon { // display: inline-block; // border-right: 1px solid rgba(0, 0, 0, 0.2); diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index d857b150c..60be9775c 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -63,6 +63,9 @@ import { schema } from './schema_rts'; import { SummaryView } from './SummaryView'; import applyDevTools = require('prosemirror-dev-tools'); import React = require('react'); +import { Configuration, OpenAIApi } from 'openai'; +import { Networking } from '../../../Network'; +import { gptAPICall, GPTCallType } from '../../../apis/gpt/Summarization'; import { PinProps, PresBox } from '../trails'; const translateGoogleApi = require('translate-google-api'); @@ -170,6 +173,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps }; } + // State for GPT + @observable + private gptRes: string = ''; + public static PasteOnLoad: ClipboardEvent | undefined; public static SelectOnLoad = ''; public static DontSelectInitialText = false; // whether initial text should be selected or not @@ -844,12 +851,84 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps const options = cm.findByDescription('Options...'); const optionItems = options && 'subitems' in options ? options.subitems : []; + optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' }); + optionItems.push({ description: `Ask GPT-3`, event: () => this.askGPT(), icon: 'lightbulb' }); optionItems.push({ description: !this.Document._singleLine ? 'Make Single Line' : 'Make Multi Line', event: () => (this.layoutDoc._singleLine = !this.layoutDoc._singleLine), icon: !this.Document._singleLine ? 'grip-lines' : 'bars' }); optionItems.push({ description: `${this.Document._autoHeight ? 'Lock' : 'Auto'} Height`, event: () => (this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight), icon: this.Document._autoHeight ? 'lock' : 'unlock' }); !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' }); this._downX = this._downY = Number.NaN; }; + mockGPT = async (): Promise<string> => { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve('Mock GPT Call.'); + }, 2000); + }); + }; + + animateRes = (resIndex: number) => { + if (resIndex < this.gptRes.length) { + this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + this.gptRes[resIndex]; + setTimeout(() => { + this.animateRes(resIndex + 1); + }, 20); + } + }; + + askGPT = action(async () => { + try { + let res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION); + // let res = await this.mockGPT(); + if (res) { + this.gptRes = res; + this.animateRes(0); + } + } catch (err) { + console.log(err); + this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + 'Something went wrong'; + } + }); + + 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); + 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, + }) + if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) { + newDoc.overlayX = this.rootDoc.x; + newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height); + Doc.AddDocToList(Doc.MyOverlayDocs, undefined, newDoc); + } else { + this.props.addDocument?.(newDoc); + } + // Create link between prompt and image + DocUtils.MakeLink({doc: this.rootDoc}, {doc: newDoc}, "Image Prompt"); + } + } catch (err) { + console.log(err); + return ''; + } + }; + breakupDictation = () => { if (this._editorView && this._recording) { this.stopDictation(true); diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index fc8f1da49..1b30e1f68 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -10,6 +10,8 @@ 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 { LightboxView } from '../LightboxView'; @@ -43,6 +45,28 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { @observable public Status: 'marquee' | 'annotation' | '' = ''; + // GPT additions + @observable private summarizedText: string = ''; + @observable private loadingSummary: boolean = false; + @observable private showGPTPopup: boolean = false; + @action + setGPTPopupVis = (vis: boolean) => { + this.showGPTPopup = vis; + }; + @action + setSummarizedText = (txt: string) => { + this.summarizedText = txt; + }; + @action + setLoading = (loading: boolean) => { + this.loadingSummary = loading; + }; + + private selectedText: string = ''; + public setSelectedText = (txt: string) => { + this.selectedText = txt; + }; + public onMakeAnchor: () => Opt<Doc> = () => undefined; // Method to get anchor from text search public OnCrop: (e: PointerEvent) => void = unimplementedFunction; @@ -76,18 +100,56 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { componentDidMount() { this._disposer2 = reaction( () => this._opacity, - opacity => !opacity && (this._showLinkPopup = false), + opacity => { + if (!opacity) { + this._showLinkPopup = false; + this.setGPTPopupVis(false); + this.setSummarizedText(''); + } + }, { fireImmediately: true } ); this._disposer = reaction( () => SelectionManager.Views(), selected => { this._showLinkPopup = false; + this.setGPTPopupVis(false); + this.setSummarizedText(''); AnchorMenu.Instance.fadeOut(true); } ); } + /** + * Returns a mock summary simulating an API call. + * @returns A Promise that resolves into a string + */ + mockSummarize = async (): Promise<string> => { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve('Mock summary. This is a summary of the highlighted text.'); + }, 1000); + }); + }; + + /** + * Invokes the API with the selected text and stores it in the summarized text. + * @param e pointer down event + */ + gptSummarize = async (e: React.PointerEvent) => { + this.setGPTPopupVis(true); + 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.'); + } + + this.setLoading(false); + }; + pointerDown = (e: React.PointerEvent) => { setupMoveUpEvents( this, @@ -180,6 +242,19 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { this.highlightColor = Utils.colorString(col); }; + /** + * Returns whether the selected text can be summarized. The goal is to have + * all selected text available to summarize but its only supported for pdf and web ATM. + * @returns Whether the GPT icon for summarization should appear + */ + canSummarize = (): boolean => { + const docs = SelectionManager.Docs(); + if (docs.length > 0) { + return docs[0].type === 'pdf' || docs[0].type === 'web'; + } + return false; + }; + render() { const buttons = this.Status === 'marquee' ? ( @@ -190,6 +265,15 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { <FontAwesomeIcon icon="comment-alt" size="lg" /> </button> </Tooltip> + {/* GPT Summarize icon only shows up when text is highlighted, not on marquee selection*/} + {AnchorMenu.Instance.StartCropDrag === unimplementedFunction && this.canSummarize() && ( + <Tooltip key="gpt" title={<div className="dash-tooltip">Summarize with AI</div>}> + <button className="antimodeMenu-button annotate" onPointerDown={this.gptSummarize} style={{ cursor: 'grab' }}> + <FontAwesomeIcon icon="comment-dots" size="lg" /> + </button> + </Tooltip> + )} + <GPTPopup key="gptpopup" visible={this.showGPTPopup} text={this.summarizedText} loadingSummary={this.loadingSummary} callApi={this.gptSummarize} /> {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' }}> diff --git a/src/client/views/pdf/GPTPopup.scss b/src/client/views/pdf/GPTPopup.scss new file mode 100644 index 000000000..7b7d2e3f7 --- /dev/null +++ b/src/client/views/pdf/GPTPopup.scss @@ -0,0 +1,123 @@ +$textgrey: #707070; +$lighttextgrey: #a3a3a3; +$greyborder: #d3d3d3; +$lightgrey: #ececec; +$button: #5b97ff; + +.summary-box { + display: flex; + flex-direction: column; + background-color: #ffffff; + box-shadow: 0 2px 5px #7474748d; + color: $textgrey; + position: absolute; + bottom: 40px; + width: 250px; + border-radius: 15px; + padding: 15px; + padding-bottom: 0px; + + .summary-heading { + display: flex; + align-items: center; + border-bottom: 1px solid $greyborder; + padding-bottom: 5px; + + .summary-text { + font-size: 12px; + font-weight: 500; + } + } + + label { + color: $textgrey; + font-size: 12px; + font-weight: 400; + letter-spacing: 1px; + margin: 0; + padding-right: 5px; + } + + a { + cursor: pointer; + } + + .content-wrapper { + padding-top: 10px; + min-height: 50px; + max-height: 150px; + overflow-y: auto; + } + + .btns-wrapper { + height: 50px; + display: flex; + justify-content: space-between; + align-items: center; + + .summarizing { + display: flex; + align-items: center; + } + } + + button { + font-size: 9px; + padding: 10px; + color: #ffffff; + background-color: $button; + border-radius: 5px; + } + + .text-btn { + &:hover { + background-color: $button; + } + } + + .btn-secondary { + font-size: 8px; + padding: 10px 5px; + background-color: $lightgrey; + color: $textgrey; + &:hover { + background-color: $lightgrey; + } + } + + .icon-btn { + background-color: #ffffff; + padding: 10px; + border-radius: 50%; + color: $button; + border: 1px solid $button; + } + + .ai-warning { + padding: 10px 0; + font-size: 10px; + color: $lighttextgrey; + border-top: 1px solid $greyborder; + } +} + +// Typist CSS +.Typist .Cursor { + display: inline-block; +} +.Typist .Cursor--blinking { + opacity: 1; + animation: blink 1s linear infinite; +} + +@keyframes blink { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/src/client/views/pdf/GPTPopup.tsx b/src/client/views/pdf/GPTPopup.tsx new file mode 100644 index 000000000..ec4fa58dc --- /dev/null +++ b/src/client/views/pdf/GPTPopup.tsx @@ -0,0 +1,131 @@ +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/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index 86d14c22e..3f891789a 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -24,6 +24,7 @@ import { AnchorMenu } from './AnchorMenu'; import { Annotation } from './Annotation'; import './PDFViewer.scss'; import React = require('react'); +import { GPTPopup } from './GPTPopup'; const PDFJSViewer = require('pdfjs-dist/web/pdf_viewer'); const pdfjsLib = require('pdfjs-dist'); const _global = (window /* browser */ || global) /* node */ as any; @@ -40,9 +41,10 @@ interface IViewerProps extends FieldViewProps { fieldKey: string; pdf: Pdfjs.PDFDocumentProxy; url: string; + sidebarAddDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean; loaded?: (nw: number, nh: number, np: number) => void; setPdfViewer: (view: PDFViewer) => void; - anchorMenuClick?: () => undefined | ((anchor: Doc) => void); + anchorMenuClick?: () => undefined | ((anchor: Doc, summarize?: boolean) => void); crop: (region: Doc | undefined, addCrop?: boolean) => Doc | undefined; } @@ -415,10 +417,18 @@ export class PDFViewer extends React.Component<IViewerProps> { document.removeEventListener('pointerup', this.onSelectEnd); const sel = window.getSelection(); + if (sel) { + AnchorMenu.Instance.setSelectedText(sel.toString()); + } + if (sel?.type === 'Range') { this.createTextAnnotation(sel, sel.getRangeAt(0)); AnchorMenu.Instance.jumpTo(e.clientX, e.clientY); } + + // Changing which document to add the annotation to (the currently selected PDF) + GPTPopup.Instance.setSidebarId('data-sidebar'); + GPTPopup.Instance.addDoc = this.props.sidebarAddDoc; }; @action |