diff options
author | bobzel <zzzman@gmail.com> | 2024-05-16 20:39:30 -0400 |
---|---|---|
committer | bobzel <zzzman@gmail.com> | 2024-05-16 20:39:30 -0400 |
commit | a197d8d37e62235bfa59bc8e3bb74c39381c30a4 (patch) | |
tree | d24ae3d11db6f3f64d1c8571991cfb8fac244e0f /src | |
parent | 84098c41aaa4970d43b88645489e64c1cac22934 (diff) | |
parent | 2caf7b7bb80b663b6ba585f88cdbd2d725f8505e (diff) |
Merge branch 'master' into eleanor-starter
Diffstat (limited to 'src')
-rw-r--r-- | src/client/apis/gpt/GPT.ts | 33 | ||||
-rw-r--r-- | src/client/views/PropertiesView.scss | 5 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/CollectionFreeFormBackgroundGrid.tsx | 10 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx | 43 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/DataVizBox.scss | 4 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/DataVizBox.tsx | 35 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/components/Chart.scss | 63 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/components/TableBox.tsx | 201 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBox.tsx | 22 | ||||
-rw-r--r-- | src/client/views/pdf/AnchorMenu.tsx | 15 | ||||
-rw-r--r-- | src/client/views/pdf/GPTPopup/GPTPopup.tsx | 152 | ||||
-rw-r--r-- | src/client/views/pdf/PDFViewer.tsx | 2 |
12 files changed, 464 insertions, 121 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index fb51278ae..30194f9f8 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -1,9 +1,11 @@ import { ClientOptions, OpenAI } from 'openai'; +import { ChatCompletionMessageParam } from 'openai/resources'; enum GPTCallType { SUMMARY = 'summary', COMPLETION = 'completion', EDIT = 'edit', + DATA = 'data', } type GPTCallOpts = { @@ -13,10 +15,16 @@ type GPTCallOpts = { prompt: string; }; +/** + * Replace completions (deprecated) with chat + */ + const callTypeMap: { [type: string]: GPTCallOpts } = { - summary: { model: 'gpt-3.5-turbo-instruct', maxTokens: 256, temp: 0.5, prompt: 'Summarize this text in simpler terms: ' }, - edit: { model: 'gpt-3.5-turbo-instruct', maxTokens: 256, temp: 0.5, prompt: 'Reword this: ' }, - completion: { model: 'gpt-3.5-turbo-instruct', maxTokens: 256, temp: 0.5, prompt: '' }, + // newest model: gpt-4 + summary: { model: 'gpt-3.5-turbo', maxTokens: 256, temp: 0.5, prompt: 'Summarize the text given in simpler terms.' }, + edit: { model: 'gpt-3.5-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' }, + completion: { model: 'gpt-3.5-turbo', maxTokens: 256, temp: 0.5, prompt: "You are a helpful assistant. Answer the user's prompt." }, + data: { model: 'gpt-3.5-turbo', maxTokens: 256, temp: 0.5, prompt: "You are a helpful resarch assistant. Analyze the user's data to find meaningful patterns and/or correlation. Please keep your response short and to the point." }, }; /** @@ -25,7 +33,7 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { * @param inputText Text to process * @returns AI Output */ -const gptAPICall = async (inputText: string, callType: GPTCallType) => { +const gptAPICall = async (inputText: string, callType: GPTCallType, prompt?: any) => { if (callType === GPTCallType.SUMMARY) inputText += '.'; const opts: GPTCallOpts = callTypeMap[callType]; try { @@ -34,13 +42,21 @@ const gptAPICall = async (inputText: string, callType: GPTCallType) => { dangerouslyAllowBrowser: true, }; const openai = new OpenAI(configuration); - const response = await openai.completions.create({ + + let usePrompt = prompt ? opts.prompt + prompt : opts.prompt; + let messages: ChatCompletionMessageParam[] = [ + { role: 'system', content: usePrompt }, + { role: 'user', content: inputText }, + ]; + + const response = await openai.chat.completions.create({ model: opts.model, max_tokens: opts.maxTokens, temperature: opts.temp, - prompt: `${opts.prompt}${inputText}`, + messages, }); - return response.choices[0].text; + const content = response.choices[0].message.content; + return content; } catch (err) { console.log(err); return 'Error connecting with API.'; @@ -60,8 +76,7 @@ const gptImageCall = async (prompt: string, n?: number) => { n: n ?? 1, size: '1024x1024', }); - return response.data.map((data: any) => data.url); - // return response.data.data[0].url; + return response.data.map(data => data.url); } catch (err) { console.error(err); return; diff --git a/src/client/views/PropertiesView.scss b/src/client/views/PropertiesView.scss index 8581bdf73..476b46905 100644 --- a/src/client/views/PropertiesView.scss +++ b/src/client/views/PropertiesView.scss @@ -227,14 +227,15 @@ font-weight: bold; width: 95px; overflow-x: hidden; - display: inline-block; text-overflow: ellipsis; white-space: nowrap; + display: flex; + align-items: center; } .propertiesView-sharingTable-item-permission { display: flex; - align-items: flex-end; + align-items: center; text-align: right; margin-left: auto; margin-right: -12px; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormBackgroundGrid.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormBackgroundGrid.tsx index 08dfb32ad..0acc99360 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormBackgroundGrid.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormBackgroundGrid.tsx @@ -14,8 +14,8 @@ export interface CollectionFreeFormViewBackgroundGridProps { nativeDimScaling: () => number; zoomScaling: () => number; layoutDoc: Doc; - cachedCenteringShiftX: number; - cachedCenteringShiftY: number; + centeringShiftX: number; + centeringShiftY: number; } @observer export class CollectionFreeFormBackgroundGrid extends React.Component<CollectionFreeFormViewBackgroundGridProps> { @@ -32,7 +32,7 @@ export class CollectionFreeFormBackgroundGrid extends React.Component<Collection const w = this.props.PanelWidth() / this.props.nativeDimScaling() + 2 * renderGridSpace; const h = this.props.PanelHeight() / this.props.nativeDimScaling() + 2 * renderGridSpace; const strokeStyle = this.props.color(); - return !this.props.nativeDimScaling() ? null : ( + return ( <canvas className="collectionFreeFormView-grid" width={w} @@ -41,8 +41,8 @@ export class CollectionFreeFormBackgroundGrid extends React.Component<Collection ref={el => { const ctx = el?.getContext('2d'); if (ctx) { - const Cx = this.props.cachedCenteringShiftX % renderGridSpace; - const Cy = this.props.cachedCenteringShiftY % renderGridSpace; + const Cx = this.props.centeringShiftX % renderGridSpace; + const Cy = this.props.centeringShiftY % renderGridSpace; ctx.lineWidth = Math.min(1, Math.max(0.5, this.props.zoomScaling())); ctx.setLineDash(gridSpace > 50 ? [3, 3] : [1, 5]); ctx.clearRect(0, 0, w, h); diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index e2965c1ba..06c6f58a1 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -173,17 +173,19 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @computed get nativeHeight() { return this._props.NativeHeight?.() || Doc.NativeHeight(this.Document, Cast(this.Document.resolvedDataDoc, Doc, null)); } - @computed get cachedCenteringShiftX(): number { - const scaling = !this.nativeDimScaling ? 1 : this.nativeDimScaling; + @computed get centeringShiftX(): number { + const scaling = this.nativeDimScaling; return this._props.isAnnotationOverlay || this._props.originTopLeft ? 0 : this._props.PanelWidth() / 2 / scaling; // shift so pan position is at center of window for non-overlay collections } - @computed get cachedCenteringShiftY(): number { + @computed get centeringShiftY(): number { + const panLocAtCenter = !(this._props.isAnnotationOverlay || this._props.originTopLeft); + if (!panLocAtCenter) return 0; const dv = this.DocumentView?.(); - const fitWidth = this._props.layout_fitWidth?.(this.Document) ?? dv?.layoutDoc.layout_fitWidth; - const scaling = !this.nativeDimScaling ? 1 : this.nativeDimScaling; + const aspect = !(this._props.layout_fitWidth?.(this.Document) ?? dv?.layoutDoc.layout_fitWidth) && dv?.nativeWidth && dv?.nativeHeight; + const scaling = this.nativeDimScaling; // if freeform has a native aspect, then the panel height needs to be adjusted to match it - const aspect = dv?.nativeWidth && dv?.nativeHeight && !fitWidth ? dv.nativeHeight / dv.nativeWidth : this._props.PanelHeight() / this._props.PanelWidth(); - return this._props.isAnnotationOverlay || this._props.originTopLeft ? 0 : (aspect * this._props.PanelWidth()) / 2 / scaling; // shift so pan position is at center of window for non-overlay collections + const height = aspect ? (dv.nativeHeight / dv.nativeWidth) * this._props.PanelWidth() : this._props.PanelHeight(); + return height / 2 / scaling; // shift so pan position is at center of window for non-overlay collections } @computed get panZoomXf() { return new Transform(this.panX(), this.panY(), 1 / this.zoomScaling()); @@ -191,7 +193,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @computed get screenToFreeformContentsXf() { return this._props .ScreenToLocalTransform() // - .translate(-this.cachedCenteringShiftX, -this.cachedCenteringShiftY) + .translate(-this.centeringShiftX, -this.centeringShiftY) .transform(this.panZoomXf); } @@ -254,8 +256,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection panX = () => this.freeformData()?.bounds.cx ?? NumCast(this.Document[this.panXFieldKey], NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.freeform_panX, 1)); panY = () => this.freeformData()?.bounds.cy ?? NumCast(this.Document[this.panYFieldKey], NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.freeform_panY, 1)); zoomScaling = () => this.freeformData()?.scale ?? NumCast(Doc.Layout(this.Document)[this.scaleFieldKey], 1); //, NumCast(DocCast(this.Document.resolvedDataDoc)?.[this.scaleFieldKey], 1)); - PanZoomCenterXf = () => - this._props.isAnnotationOverlay && this.zoomScaling() === 1 ? `` : `translate(${this.cachedCenteringShiftX}px, ${this.cachedCenteringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`; + PanZoomCenterXf = () => (this._props.isAnnotationOverlay && this.zoomScaling() === 1 ? `` : `translate(${this.centeringShiftX}px, ${this.centeringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`); ScreenToContentsXf = () => this.screenToFreeformContentsXf.copy(); getActiveDocuments = () => this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout); isAnyChildContentActive = () => this._props.isAnyChildContentActive(); @@ -1570,9 +1571,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection if (scale !== undefined) { const maxZoom = 5; // sets the limit for how far we will zoom. this is useful for preventing small text boxes from filling the screen. So probably needs to be more sophisticated to consider more about the target and context const newScale = - scale === 0 - ? NumCast(this.layoutDoc[this.scaleFieldKey]) - : Math.min(maxZoom, (1 / (this.nativeDimScaling || 1)) * scale * Math.min(this._props.PanelWidth() / Math.abs(bounds.width), this._props.PanelHeight() / Math.abs(bounds.height))); + scale === 0 ? NumCast(this.layoutDoc[this.scaleFieldKey]) : Math.min(maxZoom, (1 / this.nativeDimScaling) * scale * Math.min(this._props.PanelWidth() / Math.abs(bounds.width), this._props.PanelHeight() / Math.abs(bounds.height))); return { panX: this._props.isAnnotationOverlay ? bounds.left - (Doc.NativeWidth(this.layoutDoc) / newScale - bounds.width) / 2 : (bounds.left + bounds.right) / 2, panY: this._props.isAnnotationOverlay ? bounds.top - (Doc.NativeHeight(this.layoutDoc) / newScale - bounds.height) / 2 : (bounds.top + bounds.bot) / 2, @@ -1869,7 +1868,11 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection presentation_transition: 500, annotationOn: this.Document, }); - PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), pannable: !this.Document.isGroup, type_collection: true, filters: true } }, this.Document); + PresBox.pinDocView( + anchor, + { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ? { ...pinProps.pinData, poslayoutview: pinProps.pinData.dataview } : {}), pannable: !this.Document.isGroup, type_collection: true, filters: true } }, + this.Document + ); if (addAsAnnotation) { if (Cast(this.dataDoc[this._props.fieldKey + '_annotations'], listSpec(Doc), null) !== undefined) { @@ -2232,8 +2235,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection zoomScaling={this.zoomScaling} layoutDoc={this.layoutDoc} isAnnotationOverlay={this.isAnnotationOverlay} - cachedCenteringShiftX={this.cachedCenteringShiftX} - cachedCenteringShiftY={this.cachedCenteringShiftY} + centeringShiftX={this.centeringShiftX} + centeringShiftY={this.centeringShiftY} /> </div> ); @@ -2281,7 +2284,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } @computed get nativeDimScaling() { - if (this._firstRender || (this._props.isAnnotationOverlay && !this._props.annotationLayerHostsContent)) return 0; + if (this._firstRender || (this._props.isAnnotationOverlay && !this._props.annotationLayerHostsContent)) return 1; const nw = this.nativeWidth; const nh = this.nativeHeight; const hscale = nh ? this._props.PanelHeight() / nh : 1; @@ -2337,9 +2340,9 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection style={{ pointerEvents: this._props.isContentActive() && SnappingManager.IsDragging ? 'all' : (this._props.pointerEvents?.() as any), textAlign: this.isAnnotationOverlay ? 'initial' : undefined, - transform: `scale(${this.nativeDimScaling || 1})`, - width: `${100 / (this.nativeDimScaling || 1)}%`, - height: this._props.getScrollHeight?.() ?? `${100 / (this.nativeDimScaling || 1)}%`, + transform: `scale(${this.nativeDimScaling})`, + width: `${100 / this.nativeDimScaling}%`, + height: this._props.getScrollHeight?.() ?? `${100 / this.nativeDimScaling}%`, }}> {(Doc.ActiveTool === InkTool.RadiusEraser && this._showEraserCircle) && ( <div diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.scss b/src/client/views/nodes/DataVizBox/DataVizBox.scss index 6b5738790..e9a346fbe 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.scss +++ b/src/client/views/nodes/DataVizBox/DataVizBox.scss @@ -32,6 +32,10 @@ .liveSchema-checkBox { margin-bottom: -35px; } + + .displaySchemaLive { + margin-bottom: 20px; + } .dataviz-sidebar { position: absolute; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 22f1f7b79..60c5fdba2 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -18,7 +18,7 @@ import { ViewBoxAnnotatableComponent, ViewBoxInterface } from '../../DocComponen import { MarqueeAnnotator } from '../../MarqueeAnnotator'; import { SidebarAnnos } from '../../SidebarAnnos'; import { AnchorMenu } from '../../pdf/AnchorMenu'; -import { GPTPopup } from '../../pdf/GPTPopup/GPTPopup'; +import { GPTPopup, GPTPopupMode } from '../../pdf/GPTPopup/GPTPopup'; import { DocumentView } from '../DocumentView'; import { FocusViewOptions, FieldView, FieldViewProps } from '../FieldView'; import { PinProps } from '../trails'; @@ -28,6 +28,7 @@ import { LineChart } from './components/LineChart'; import { PieChart } from './components/PieChart'; import { TableBox } from './components/TableBox'; import { Checkbox } from '@mui/material'; +import { ContextMenu } from '../../ContextMenu'; export enum DataVizView { TABLE = 'table', @@ -43,6 +44,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); private _disposers: { [name: string]: IReactionDisposer } = {}; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); + sidebarAddDoc: ((doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean) | undefined; crop: ((region: Doc | undefined, addCrop?: boolean) => Doc | undefined) | undefined; @observable _marqueeing: number[] | undefined = undefined; @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); @@ -400,8 +402,28 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im @action changeLiveSchemaCheckbox = () => { - this.layoutDoc.dataViz_schemaLive = !this.layoutDoc.dataViz_schemaLive; - }; + this.layoutDoc.dataViz_schemaLive = !this.layoutDoc.dataViz_schemaLive + } + + specificContextMenu = (e: React.MouseEvent): void => { + const cm = ContextMenu.Instance; + const options = cm.findByDescription('Options...'); + const optionItems = options && 'subitems' in options ? options.subitems : []; + optionItems.push({ description: `Analyze with AI`, event: () => this.askGPT(), icon: 'lightbulb' }); + !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' }); + } + + + askGPT = action(async () => { + GPTPopup.Instance.setSidebarId('data_sidebar'); + GPTPopup.Instance.addDoc = this.sidebarAddDocument; + GPTPopup.Instance.setDataJson(""); + GPTPopup.Instance.setMode(GPTPopupMode.DATA); + let data = DataVizBox.dataset.get(CsvCast(this.dataDoc[this.fieldKey]).url.href); + let input = JSON.stringify(data); + GPTPopup.Instance.setDataJson(input); + GPTPopup.Instance.generateDataAnalysis(); + }); render() { const scale = this._props.NativeDimScaling?.() || 1; @@ -419,6 +441,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im transform: `scale(${scale})`, position: 'absolute', }} + onContextMenu={this.specificContextMenu} onWheel={e => e.stopPropagation()} ref={this._mainCont}> <div className="datatype-button"> @@ -428,11 +451,13 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im <Toggle text={'PIE CHART'} toggleType={ToggleType.BUTTON} type={Type.SEC} color={'black'} onClick={e => (this.layoutDoc._dataViz = DataVizView.PIECHART)} toggleStatus={this.layoutDoc._dataViz == -DataVizView.PIECHART} /> </div> - {this.layoutDoc && this.layoutDoc.dataViz_asSchema ? ( - <div className={'liveSchema-checkBox'} style={{ width: this._props.width }}> + {(this.layoutDoc && this.layoutDoc.dataViz_asSchema)?( + <div className={'displaySchemaLive'}> + <div className={'liveSchema-checkBox'} style={{ width: this._props.width }}> <Checkbox color="primary" onChange={this.changeLiveSchemaCheckbox} checked={this.layoutDoc.dataViz_schemaLive as boolean} /> Display Live Updates to Canvas </div> + </div> ) : null} {this.renderVizView} diff --git a/src/client/views/nodes/DataVizBox/components/Chart.scss b/src/client/views/nodes/DataVizBox/components/Chart.scss index 41ce637ac..cf0007cfd 100644 --- a/src/client/views/nodes/DataVizBox/components/Chart.scss +++ b/src/client/views/nodes/DataVizBox/components/Chart.scss @@ -120,11 +120,62 @@ } } } -.selectAll-buttons { - display: flex; - flex-direction: row; - justify-content: flex-end; +.tableBox-selectButtons { margin-top: 5px; - margin-right: 10px; - float: right; + margin-left: 25px; + display: inline-block; + padding: 2px; + .tableBox-selectTitle { + display: inline-flex; + flex-direction: row; + } + .tableBox-filtering { + display: flex; + flex-direction: row; + float: right; + margin-right: 10px; + .tableBox-filterAll { + min-width: 75px; + } + } +} + +.tableBox-filterPopup { + background: $light-gray; + position: absolute; + min-width: 235px; + top: 60px; + display: flex; + flex-direction: column; + align-items: flex-start; + z-index: 2; + padding: 7px; + border-radius: 5px; + margin: 3px; + .tableBox-filterPopup-selectColumn { + margin-top: 5px; + flex-direction: row; + .tableBox-filterPopup-selectColumn-each { + margin-left: 25px; + border-radius: 3px; + background: $light-gray; + } + } + .tableBox-filterPopup-setValue { + margin-top: 5px; + display: flex; + flex-direction: row; + .tableBox-filterPopup-setValue-each { + margin-right: 5px; + border-radius: 3px; + background: $light-gray; + } + .tableBox-filterPopup-setValue-input { + margin: 5px; + } + } + .tableBox-filterPopup-setFilter { + margin-top: 5px; + align-self: center; + } } diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx index 53d1869d9..67e1c67bd 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -12,6 +12,7 @@ import { ObservableReactComponent } from '../../../ObservableReactComponent'; import { DocumentView } from '../../DocumentView'; import { DataVizView } from '../DataVizBox'; import './Chart.scss'; +import { undoable } from '../../../../util/UndoManager'; const { DATA_VIZ_TABLE_ROW_HEIGHT } = require('../../../global/globalCssVariables.module.scss'); // prettier-ignore interface TableBoxProps { Document: Doc; @@ -37,6 +38,13 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { _inputChangedDisposer?: IReactionDisposer; _containerRef: HTMLDivElement | null = null; + @observable settingTitle: boolean = false; // true when setting a title column + @observable hasRowsToFilter: boolean = false; // true when any rows are selected + @observable filtering: boolean = false; // true when the filtering menu is open + @observable filteringColumn: any = ''; // column to filter + @observable filteringType: string = 'Value'; // "Value" or "Range" + filteringVal: any[] = ['', '']; // value or range to filter the column with + @observable _scrollTop = -1; @observable _tableHeight = 0; @observable _tableContainerHeight = 0; @@ -49,6 +57,8 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { // if the tableData changes (ie., when records are selected by the parent (input) visulization), // then we need to remove any selected rows that are no longer part of the visualized dataset. this._inputChangedDisposer = reaction(() => this._tableData.slice(), this.filterSelectedRowsDown, { fireImmediately: true }); + const selected = NumListCast(this._props.layoutDoc.dataViz_selectedRows); + if (selected.length > 0) this.hasRowsToFilter = true; this.handleScroll(); } componentWillUnmount() { @@ -64,9 +74,6 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); - // return LinkManager.Instance.getAllRelatedLinks(this._props.Document) // out of all links - // .filter(link => link.link_anchor_1 == this._props.Document.dataViz_parentViz) // get links where this chart doc is the target of the link - // .map(link => DocCast(link.link_anchor_1)); // then return the source of the link } @computed get columns() { @@ -115,6 +122,7 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { } else selected?.push(rowId); } e.stopPropagation(); + this.hasRowsToFilter = selected.length > 0 ? true : false; }; columnPointerDown = (e: React.PointerEvent, col: string) => { @@ -155,7 +163,8 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { }, emptyFunction, action(e => { - if (e.shiftKey) { + if (e.shiftKey || this.settingTitle) { + if (this.settingTitle) this.settingTitle = false; if (this._props.titleCol == col) this._props.titleCol = ''; else this._props.titleCol = col; this._props.selectTitleCol(this._props.titleCol); @@ -170,6 +179,134 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { ); }; + /** + * These functions handle the filtering popup for when the "filter" button is pressed to select rows + */ + filter = undoable((e: any) => { + var start: any; + var end: any; + if (this.filteringType == 'Range') { + start = (this.filteringVal[0] as Number) ? Number(this.filteringVal[0]) : this.filteringVal[0]; + end = (this.filteringVal[1] as Number) ? Number(this.filteringVal[1]) : this.filteringVal[0]; + } + + this._tableDataIds.forEach(rowID => { + if (this.filteringType == 'Value') { + if (this._props.records[rowID][this.filteringColumn] == this.filteringVal[0]) { + if (!NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowID)) { + this.tableRowClick(e, rowID); + } + } + } else { + let compare = this._props.records[rowID][this.filteringColumn]; + if (compare as Number) compare = Number(compare); + if (start <= compare && compare <= end) { + if (!NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowID)) { + this.tableRowClick(e, rowID); + } + } + } + }); + this.filtering = false; + this.filteringColumn = ''; + this.filteringVal = ['', '']; + }, 'filter table'); + @action + setFilterColumn = (e: any) => { + this.filteringColumn = e.currentTarget.value; + }; + @action + setFilterType = (e: any) => { + this.filteringType = e.currentTarget.value; + }; + changeFilterValue = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.filteringVal[0] = e.target.value; + }); + changeFilterRange0 = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.filteringVal[0] = e.target.value; + }); + changeFilterRange1 = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.filteringVal[1] = e.target.value; + }); + @computed get renderFiltering() { + if (this.filteringColumn === '') this.filteringColumn = this.columns[0]; + return ( + <div className="tableBox-filterPopup" style={{ right: this._props.width * 0.05 }}> + <div className="tableBox-filterPopup-selectColumn"> + Column: + <select className="tableBox-filterPopup-selectColumn-each" value={this.filteringColumn != '' ? this.filteringColumn : this.columns[0]} onChange={e => this.setFilterColumn(e)}> + {this.columns.map(column => ( + <option className="" key={column} value={column}> + {' '} + {column}{' '} + </option> + ))} + </select> + </div> + <div className="tableBox-filterPopup-setValue"> + <select className="tableBox-filterPopup-setValue-each" value={this.filteringType} onChange={e => this.setFilterType(e)}> + <option className="" key={'Value'} value={'Value'}> + {' '} + {'Value'}{' '} + </option> + <option className="" key={'Range'} value={'Range'}> + {' '} + {'Range'}{' '} + </option> + </select> + : + {this.filteringType == 'Value' ? ( + <input + className="tableBox-filterPopup-setValue-input" + defaultValue="" + autoComplete="off" + onChange={this.changeFilterValue} + onKeyDown={e => { + e.stopPropagation(); + }} + type="text" + placeholder="" + id="search-input" + /> + ) : ( + <div> + <input + className="tableBox-filterPopup-setValue-input" + defaultValue="" + autoComplete="off" + onChange={this.changeFilterRange0} + onKeyDown={e => { + e.stopPropagation(); + }} + type="text" + placeholder="" + id="search-input" + style={{ width: this._props.width * 0.15 }} + /> + to + <input + className="tableBox-filterPopup-setValue-input" + defaultValue="" + autoComplete="off" + onChange={this.changeFilterRange1} + onKeyDown={e => { + e.stopPropagation(); + }} + type="text" + placeholder="" + id="search-input" + style={{ width: this._props.width * 0.15 }} + /> + </div> + )} + </div> + <div className="tableBox-filterPopup-setFilter"> + <Button onClick={action(e => this.filter(e))} text="Set Filter" type={Type.SEC} color={'black'} /> + </div> + </div> + ); + } + render() { if (this._tableData.length > 0) { return ( @@ -183,9 +320,39 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { this._props.layoutDoc.dataViz_selectedRows = new List<number>(this._tableDataIds); } }}> - <div className="selectAll-buttons"> - <Button onClick={action(() => (this._props.layoutDoc.dataViz_selectedRows = new List<number>(this._tableDataIds)))} text="Select All" type={Type.SEC} color={'black'} /> - <Button onClick={action(() => (this._props.layoutDoc.dataViz_selectedRows = new List<number>()))} text="Deselect All" type={Type.SEC} color={'black'} /> + <div className="tableBox-selectButtons"> + <div className="tableBox-selectTitle"> + <Button onClick={action(() => (this.settingTitle = !this.settingTitle))} text="Select Title Column" type={Type.SEC} color={'black'} /> + </div> + <div className="tableBox-filtering"> + {this.filtering ? this.renderFiltering : null} + <Button onClick={action(() => (this.filtering = !this.filtering))} text="Filter" type={Type.SEC} color={'black'} /> + <div className="tableBox-filterAll"> + {this.hasRowsToFilter ? ( + <Button + onClick={action(() => { + this._props.layoutDoc.dataViz_selectedRows = new List<number>(); + this.hasRowsToFilter = false; + })} + text="Deselect All" + type={Type.SEC} + color={'black'} + tooltip="Select rows to be displayed in any DataViz boxes dragged off of this one." + /> + ) : ( + <Button + onClick={action(() => { + this._props.layoutDoc.dataViz_selectedRows = new List<number>(this._tableDataIds); + this.hasRowsToFilter = true; + })} + text="Select All" + type={Type.SEC} + color={'black'} + tooltip="Select rows to be displayed in any DataViz boxes dragged off of this one." + /> + )} + </div> + </div> </div> <div className={`tableBox-container ${this.columns[0]}`} @@ -227,17 +394,15 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { : this._props.axes.lastElement() === col || (this._props.axes.length > 2 && this._props.axes[1] == col) ? 'darkblue' : undefined, - background: - this._props.axes.slice().reverse().lastElement() === col - ? '#E3fbdb' - : this._props.axes.length > 2 && this._props.axes.lastElement() === col - ? '#Fbdbdb' - : this._props.axes.lastElement() === col || (this._props.axes.length > 2 && this._props.axes[1] == col) - ? '#c6ebf7' - : undefined, - // blue: #ADD8E6 - // green: #E3fbdb - // red: #Fbdbdb + background: this.settingTitle + ? 'lightgrey' + : this._props.axes.slice().reverse().lastElement() === col + ? '#E3fbdb' + : this._props.axes.length > 2 && this._props.axes.lastElement() === col + ? '#Fbdbdb' + : this._props.axes.lastElement() === col || (this._props.axes.length > 2 && this._props.axes[1] == col) + ? '#c6ebf7' + : undefined, fontWeight: 'bolder', border: '3px solid black', }} diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 5c59f7f60..43010b2ed 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -8,7 +8,7 @@ import { history } from 'prosemirror-history'; import { inputRules } from 'prosemirror-inputrules'; import { keymap } from 'prosemirror-keymap'; import { Fragment, Mark, Node, Slice } from 'prosemirror-model'; -import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from 'prosemirror-state'; +import { EditorState, NodeSelection, Plugin, Selection, TextSelection, Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import * as React from 'react'; import { BsMarkdownFill } from 'react-icons/bs'; @@ -983,10 +983,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB animateRes = (resIndex: number, newText: string) => { if (resIndex < newText.length) { const marks = this._editorView?.state.storedMarks ?? []; - this._editorView?.dispatch(this._editorView.state.tr.setStoredMarks(marks).insertText(newText[resIndex]).setStoredMarks(marks)); - setTimeout(() => { - this.animateRes(resIndex + 1, newText); - }, 20); + this._editorView?.dispatch(this._editorView?.state.tr.insertText(newText[resIndex]).setStoredMarks(marks)); + setTimeout(() => this.animateRes(resIndex + 1, newText), 20); } }; @@ -994,13 +992,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB try { let res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION); if (!res) { - console.error('GPT call failed'); this.animateRes(0, 'Something went wrong.'); - } else { - this.animateRes(0, res); + } else if (this._editorView) { + const { dispatch, state } = this._editorView; + // for no animation, use: dispatch(state.tr.insertText(res)); + // for animted response starting at end of text, use: + dispatch(state.tr.setSelection(Selection.atEnd(state.doc))); + this.animateRes(0, '\n\n' + res); } } catch (err) { - console.error('GPT call failed'); + console.error(err); this.animateRes(0, 'Something went wrong.'); } }); @@ -1255,7 +1256,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB return !whichData ? undefined : { data: RTFCast(whichData), str: Field.toString(DocCast(whichData) ?? StrCast(whichData)) }; }, incomingValue => { - if (this._editorView && this._applyingChange !== this.fieldKey && incomingValue?.data !== this.dataDoc[this.fieldKey]) { + if (this._editorView && this._applyingChange !== this.fieldKey) { if (incomingValue?.data) { const updatedState = JSON.parse(incomingValue.data.Data); if (JSON.stringify(this._editorView.state.toJSON()) !== JSON.stringify(updatedState)) { @@ -1484,6 +1485,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB this._editorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(tr.doc.content.size)))); } else if (curText && !FormattedTextBox.DontSelectInitialText) { selectAll(this._editorView.state, this._editorView?.dispatch); + this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data } } if (selectOnLoad) { diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 59f191af0..0b3ba81d3 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -6,7 +6,6 @@ import * as React from 'react'; import { ColorResult } from 'react-color'; import { Utils, returnFalse, setupMoveUpEvents, unimplementedFunction } from '../../../Utils'; import { Doc, Opt } from '../../../fields/Doc'; -import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT'; import { DocumentType } from '../../documents/DocumentTypes'; import { SelectionManager } from '../../util/SelectionManager'; import { SettingsManager } from '../../util/SettingsManager'; @@ -73,18 +72,8 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { * @param e pointer down event */ gptSummarize = async (e: React.PointerEvent) => { - // move this logic to gptpopup, need to implement generate again - GPTPopup.Instance.setVisible(true); - GPTPopup.Instance.setMode(GPTPopupMode.SUMMARY); - GPTPopup.Instance.setLoading(true); - - try { - const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY); - GPTPopup.Instance.setText(res || 'Something went wrong.'); - } catch (err) { - console.error(err); - } - GPTPopup.Instance.setLoading(false); + GPTPopup.Instance?.setSelectedText(this.selectedText); + GPTPopup.Instance.generateSummary(); }; pointerDown = (e: React.PointerEvent) => { diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx index da8a88803..29b1ca365 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx +++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Button, IconButton, Type } from 'browndash-components'; +import { Button, EditableText, IconButton, Size, Type } from 'browndash-components'; import { action, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; @@ -10,7 +10,7 @@ import { Utils } from '../../../../Utils'; import { Doc } from '../../../../fields/Doc'; import { NumCast, StrCast } from '../../../../fields/Types'; import { Networking } from '../../../Network'; -import { gptImageCall } from '../../../apis/gpt/GPT'; +import { GPTCallType, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT'; import { DocUtils, Docs } from '../../../documents/Documents'; import { ObservableReactComponent } from '../../ObservableReactComponent'; import { AnchorMenu } from '../AnchorMenu'; @@ -20,6 +20,7 @@ export enum GPTPopupMode { SUMMARY, EDIT, IMAGE, + DATA, } interface GPTPopupProps {} @@ -27,6 +28,7 @@ interface GPTPopupProps {} @observer export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { static Instance: GPTPopup; + @observable private chatMode: boolean = false; @observable public visible: boolean = false; @@ -46,6 +48,20 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { public setText = (text: string) => { this.text = text; }; + @observable + public selectedText: string = ''; + @action + public setSelectedText = (text: string) => { + this.selectedText = text; + }; + @observable + public dataJson: string = ''; + public dataChatPrompt: string | null = null; + @action + public setDataJson = (text: string) => { + if (text=="") this.dataChatPrompt = ""; + this.dataJson = text; + }; @observable public imgDesc: string = ''; @@ -79,6 +95,7 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { @action public setDone = (done: boolean) => { this.done = done; + this.chatMode = false; }; // change what can be a ref into a ref @@ -118,18 +135,46 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { try { let image_urls = await gptImageCall(this.imgDesc); + console.log('Image urls: ', image_urls); if (image_urls && image_urls[0]) { const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_urls[0]] }); + console.log('Upload result: ', result); const source = Utils.prepend(result.accessPaths.agnostic.client); + console.log('Upload source: ', source); this.setImgUrls([[image_urls[0], source]]); } } catch (err) { - console.log(err); - return ''; + console.error(err); } this.setLoading(false); }; + generateSummary = async () => { + GPTPopup.Instance.setVisible(true); + GPTPopup.Instance.setMode(GPTPopupMode.SUMMARY); + GPTPopup.Instance.setLoading(true); + + try { + const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY); + GPTPopup.Instance.setText(res || 'Something went wrong.'); + } catch (err) { + console.error(err); + } + GPTPopup.Instance.setLoading(false); + } + + generateDataAnalysis = async () => { + GPTPopup.Instance.setVisible(true); + GPTPopup.Instance.setLoading(true); + try { + let res = await gptAPICall(this.dataJson, GPTCallType.DATA, this.dataChatPrompt); + GPTPopup.Instance.setText(res || 'Something went wrong.'); + } catch (err) { + console.error(err); + } + GPTPopup.Instance.setLoading(false); + } + /** * Transfers the summarization text to a sidebar annotation text document. */ @@ -174,6 +219,16 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { DocUtils.MakeLink(textAnchor, newDoc, { link_relationship: 'Image Prompt' }); }; + /** + * Creates a chatbox for analyzing data so that users can ask specific questions. + */ + private chatWithAI = () => { + this.chatMode = true; + } + dataPromptChanged = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.dataChatPrompt = e.target.value; + }); + private getPreviewUrl = (source: string) => source.split('.').join('_m.'); constructor(props: GPTPopupProps) { @@ -213,31 +268,6 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { ); }; - data = () => { - return ( - <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> - {this.heading('GENERATED IMAGE')} - <div className="image-content-wrapper"> - {this.imgUrls.map(rawSrc => ( - <div className="img-wrapper"> - <div className="img-container"> - <img key={rawSrc[0]} src={rawSrc[0]} width={150} height={150} alt="dalle generation" /> - </div> - <div className="btn-container"> - <Button text="Save Image" onClick={() => this.transferToImage(rawSrc[1])} color={StrCast(Doc.UserDoc().userColor)} type={Type.TERT} /> - </div> - </div> - ))} - </div> - {!this.loading && ( - <> - <IconButton tooltip="Generate Again" onClick={this.generateImage} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(Doc.UserDoc().userVariantColor)} /> - </> - )} - </div> - ); - }; - summaryBox = () => ( <> <div> @@ -255,7 +285,6 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { }, 500); }, ]} - //cursor={{ hideWhenDone: true }} /> ) : ( this.text @@ -266,7 +295,7 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { <div className="btns-wrapper"> {this.done ? ( <> - <IconButton tooltip="Generate Again" onClick={this.callSummaryApi} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(Doc.UserDoc().userVariantColor)} /> + <IconButton tooltip="Generate Again" onClick={this.generateSummary} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(Doc.UserDoc().userVariantColor)} /> <Button tooltip="Transfer to text" text="Transfer To Text" onClick={this.transferToText} color={StrCast(Doc.UserDoc().userVariantColor)} type={Type.TERT} /> </> ) : ( @@ -288,6 +317,65 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { </> ); + dataAnalysisBox = () => ( + <> + <div> + {this.heading("ANALYSIS")} + <div className="content-wrapper"> + {!this.loading && + (!this.done ? ( + <TypeAnimation + speed={50} + sequence={[ + this.text, + () => { + setTimeout(() => { + this.setDone(true); + }, 500); + }, + ]} + /> + ) : ( + this.text + ))} + </div> + </div> + {!this.loading && ( + <div className="btns-wrapper"> + {this.done? + this.chatMode?( + <input + defaultValue="" + autoComplete="off" + onChange={this.dataPromptChanged} + onKeyDown={e => { + e.key === 'Enter' ? this.generateDataAnalysis() : null; + e.stopPropagation(); + }} + type="text" + placeholder="Ask GPT a question about the data..." + id="search-input" + className="searchBox-input" + style={{width: "100%"}} + /> + ) + :( + <> + <Button tooltip="Transfer to text" text="Transfer To Text" onClick={this.transferToText} color={StrCast(Doc.UserDoc().userVariantColor)} type={Type.TERT} /> + <Button tooltip="Chat with AI" text="Chat with AI" onClick={this.chatWithAI} color={StrCast(Doc.UserDoc().userVariantColor)} type={Type.TERT} /> + </> + ) : ( + <div className="summarizing"> + <span>Summarizing</span> + <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} /> + <Button text="Stop Animation" onClick={() => {this.setDone(true);}} color={StrCast(Doc.UserDoc().userVariantColor)} type={Type.TERT}/> + </div> + )} + </div> + )} + </> + ); + aiWarning = () => this.done ? ( <div className="ai-warning"> @@ -308,7 +396,7 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { render() { return ( <div className="summary-box" style={{ display: this.visible ? 'flex' : 'none' }}> - {this.mode === GPTPopupMode.SUMMARY ? this.summaryBox() : this.mode === GPTPopupMode.IMAGE ? this.imageBox() : <></>} + {this.mode === GPTPopupMode.SUMMARY? this.summaryBox() : this.mode === GPTPopupMode.DATA? this.dataAnalysisBox() : this.mode === GPTPopupMode.IMAGE ? this.imageBox() : <></>} </div> ); } diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index a582f8004..aaff2a342 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -30,7 +30,7 @@ const _global = (window /* browser */ || global) /* node */ as any; //pdfjsLib.GlobalWorkerOptions.workerSrc = `/assets/pdf.worker.js`; // The workerSrc property shall be specified. -Pdfjs.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@4.0.379/build/pdf.worker.mjs'; +Pdfjs.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@4.1.392/build/pdf.worker.mjs'; interface IViewerProps extends FieldViewProps { pdfBox: PDFBox; |