aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/client/apis/gpt/GPT.ts28
-rw-r--r--src/client/views/ViewBoxInterface.ts1
-rw-r--r--src/client/views/collections/CollectionCardDeckView.tsx157
-rw-r--r--src/client/views/collections/CollectionSubView.tsx1
-rw-r--r--src/client/views/collections/CollectionView.tsx4
-rw-r--r--src/client/views/nodes/chatbot/tools/CreateDocumentTool.ts38
-rw-r--r--src/client/views/nodes/formattedText/FormattedTextBox.tsx4
-rw-r--r--src/client/views/pdf/GPTPopup/GPTPopup.tsx231
-rw-r--r--src/fields/Doc.ts24
-rw-r--r--src/fields/Types.ts4
10 files changed, 240 insertions, 252 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index dc969da16..e1ae99d97 100644
--- a/src/client/apis/gpt/GPT.ts
+++ b/src/client/apis/gpt/GPT.ts
@@ -8,6 +8,10 @@ export enum GPTTypeStyle {
GeneralInfo = 4,
SortDocs = 5,
}
+
+export const DescriptionSeperator = '======';
+export const DocSeperator = '------';
+
enum GPTCallType {
SUMMARY = 'summary',
COMPLETION = 'completion',
@@ -41,7 +45,7 @@ type GPTCallOpts = {
prompt: string;
};
-const callTypeMap: { [type: string]: GPTCallOpts } = {
+const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
// newest model: gpt-4
summary: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Summarize the text given in simpler terms.' },
edit: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' },
@@ -69,7 +73,13 @@ const callTypeMap: { [type: string]: GPTCallOpts } = {
model: 'gpt-4o',
maxTokens: 2048,
temp: 0.25,
- prompt: "The user is going to give you a list of descriptions. Each one is separated by `======` on either side. Descriptions will vary in length, so make sure to only separate when you see `======`. Sort them by the user's specifications. Make sure each description is only in the list once. Each item should be separated by `======`. Immediately afterward, surrounded by `------` on BOTH SIDES, provide some insight into your reasoning for the way you sorted (and mention nothing about the formatting details given in this description). It is VERY important that you format it exactly as described, ensuring the proper number of `=` and `-` (6 of each) and NO commas",
+ prompt: `The user is going to give you a list of descriptions.
+ Each one is separated by '${DescriptionSeperator}' on either side.
+ Descriptions will vary in length, so make sure to only separate when you see '${DescriptionSeperator}'.
+ Sort them by the user's specifications.
+ Make sure each description is only in the list once. Each item should be separated by '${DescriptionSeperator}'.
+ Immediately afterward, surrounded by '${DocSeperator}' on BOTH SIDES, provide some insight into your reasoning for the way you sorted (and mention nothing about the formatting details given in this description).
+ It is VERY important that you format it exactly as described, ensuring the proper number of '${DescriptionSeperator[0]}' and '${DocSeperator[0]}' (${DescriptionSeperator.length} of each) and NO commas`,
},
describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' },
flashcard: {
@@ -145,20 +155,28 @@ const callTypeMap: { [type: string]: GPTCallOpts } = {
model: 'gpt-4-turbo',
maxTokens: 1024,
temp: 0,
- prompt: "I'm going to give you a list of descriptions. Each one is separated by `======` on either side. Descriptions will vary in length, so make sure to only separate when you see `======`. Based on the question the user asks, provide a subset of the given descriptions that best matches the user's specifications. Make sure each description is only in the list once. Each item should be separated by `======`. Immediately afterward, surrounded by `------` on BOTH SIDES, provide some insight into your reasoning in the 2nd person (and mention nothing about the formatting details given in this description). It is VERY important that you format it exactly as described, ensuring the proper number of `=` and `-` (6 of each) and no commas",
+ prompt: `I'm going to give you a list of descriptions.
+ Each one is separated by '${DescriptionSeperator}' on either side.
+ Descriptions will vary in length, so make sure to only separate when you see '${DescriptionSeperator}'.
+ Based on the question the user asks, provide a subset of the given descriptions that best matches the user's specifications.
+ Make sure each description is only in the list once. Each item should be separated by '${DescriptionSeperator}'.
+ Immediately afterward, surrounded by '${DocSeperator}' on BOTH SIDES, provide some insight into your reasoning in the 2nd person (and mention nothing about the formatting details given in this description).
+ It is VERY important that you format it exactly as described, ensuring the proper number of '${DescriptionSeperator[0]}' and '${DocSeperator[0]}' (${DescriptionSeperator.length} of each) and NO commas`,
},
info: {
model: 'gpt-4-turbo',
maxTokens: 1024,
temp: 0,
- prompt: "Answer the user's question with a short (<100 word) response. If a particular document is selected I will provide that information (which may help with your response)",
+ prompt: `Answer the user's question with a short (<100 word) response.
+ If a particular document is selected I will provide that information (which may help with your response)`,
},
rubric: {
model: 'gpt-4-turbo',
maxTokens: 1024,
temp: 0,
- prompt: "BRIEFLY (<25 words) provide a definition for the following term. It will be used as a rubric to evaluate the user's understanding of the topic",
+ prompt: `BRIEFLY (<25 words) provide a definition for the following term.
+ It will be used as a rubric to evaluate the user's understanding of the topic`,
},
};
let lastCall = '';
diff --git a/src/client/views/ViewBoxInterface.ts b/src/client/views/ViewBoxInterface.ts
index a66a20cf6..30da8c616 100644
--- a/src/client/views/ViewBoxInterface.ts
+++ b/src/client/views/ViewBoxInterface.ts
@@ -22,6 +22,7 @@ export abstract class ViewBoxInterface<P> extends ObservableReactComponent<React
return ''; //
}
promoteCollection?: () => void; // moves contents of collection to parent
+ hasChildDocs?: () => Doc[];
updateIcon?: (usePanelDimensions?: boolean) => Promise<void>; // updates the icon representation of the document
getAnchor?: (addAsAnnotation: boolean, pinData?: PinProps) => Doc; // returns an Anchor Doc that represents the current state of the doc's componentview (e.g., the current playhead location of a an audio/video box)
restoreView?: (viewSpec: Doc) => boolean; // DEPRECATED: do not use, it will go away. see PresBox.restoreTargetDocView
diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx
index e00aa65d7..d7f4251f3 100644
--- a/src/client/views/collections/CollectionCardDeckView.tsx
+++ b/src/client/views/collections/CollectionCardDeckView.tsx
@@ -1,22 +1,22 @@
-import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import * as CSS from 'csstype';
+import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction, runInAction } from 'mobx';
import { observer } from 'mobx-react';
import { computedFn } from 'mobx-utils';
import * as React from 'react';
-import * as CSS from 'csstype';
-import { ClientUtils, imageUrlToBase64, returnFalse, returnNever, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
+import { ClientUtils, returnFalse, returnNever, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
import { emptyFunction } from '../../../Utils';
import { Doc, DocListCast, Opt } from '../../../fields/Doc';
-import { Animation, DocData } from '../../../fields/DocSymbols';
+import { Animation } from '../../../fields/DocSymbols';
import { Id } from '../../../fields/FieldSymbols';
import { List } from '../../../fields/List';
import { ScriptField } from '../../../fields/ScriptField';
-import { BoolCast, DocCast, NumCast, RTFCast, ScriptCast, StrCast } from '../../../fields/Types';
-import { URLField } from '../../../fields/URLField';
-import { gptImageLabel, GPTTypeStyle } from '../../apis/gpt/GPT';
+import { BoolCast, DocCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types';
import { DocumentType } from '../../documents/DocumentTypes';
import { Docs } from '../../documents/Documents';
import { DragManager } from '../../util/DragManager';
import { dropActionType } from '../../util/DropActionTypes';
+import { SettingsManager } from '../../util/SettingsManager';
import { SnappingManager } from '../../util/SnappingManager';
import { Transform } from '../../util/Transform';
import { undoable, UndoManager } from '../../util/UndoManager';
@@ -25,11 +25,8 @@ import { StyleProp } from '../StyleProp';
import { TagItem } from '../TagsView';
import { DocumentView, DocumentViewProps } from '../nodes/DocumentView';
import { FocusViewOptions } from '../nodes/FocusViewOptions';
-import { GPTPopup } from '../pdf/GPTPopup/GPTPopup';
import './CollectionCardDeckView.scss';
-import { CollectionSubView, docSortings, SubCollectionViewProps } from './CollectionSubView';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { SettingsManager } from '../../util/SettingsManager';
+import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView';
/**
* New view type specifically for studying more dynamically. Allows you to reorder docs however you see fit, easily
@@ -42,7 +39,6 @@ import { SettingsManager } from '../../util/SettingsManager';
export class CollectionCardView extends CollectionSubView() {
private _dropDisposer?: DragManager.DragDropDisposer;
private _disposers: { [key: string]: IReactionDisposer } = {};
- private _textToDoc = new Map<string, Doc>();
private _oldWheel: HTMLElement | null = null;
private _dropped = false; // set when a card doc has just moved and the drop method has been called - prevents the pointerUp method from hiding doc decorations (which needs to be done when clicking on a card to animate it to front/center)
private _setCurDocScript = () => ScriptField.MakeScript('scriptContext.layoutDoc._card_curDoc=this', { scriptContext: 'any' })!;
@@ -74,22 +70,7 @@ export class CollectionCardView extends CollectionSubView() {
return Math.ceil(this.cardDeckWidth / this.cardWidth);
}
- /**
- * update's gpt's doc-text list and initialize callbacks to respond to GPT output
- */
- updateDocumentDescriptions = () =>
- this.childPairStringList().then(docDescriptions => {
- GPTPopup.Instance.setDocumentDescriptions(docDescriptions.join());
- GPTPopup.Instance.onGptResponse = this.processGptResponse;
- GPTPopup.Instance.onQuizRandom = this.randomlyChooseDoc;
- });
-
componentDidMount() {
- this._disposers.chatVis = reaction(
- () => GPTPopup.Instance.Visible,
- vis => !vis && this.onGptHide()
- );
- GPTPopup.Instance.setRetrieveDocDescriptionsCallback(this.Document, this.updateDocumentDescriptions);
this._props.setContentViewBox?.(this);
// if card deck moves, then the child doc views are hidden so their screen to local transforms will return empty rectangles
// when inquired from the dom (below in childScreenToLocal). When the doc is actually rendered, we need to act like the
@@ -110,10 +91,7 @@ export class CollectionCardView extends CollectionSubView() {
);
}
- onGptHide = () => Doc.setDocFilter(this.Document, 'tags', '#chat', 'remove');
componentWillUnmount() {
- GPTPopup.Instance.onGptResponse = undefined;
- GPTPopup.Instance.onQuizRandom = undefined;
Object.keys(this._disposers).forEach(key => this._disposers[key]?.());
this._dropDisposer?.();
}
@@ -128,7 +106,7 @@ export class CollectionCardView extends CollectionSubView() {
* Circle arc size, in radians, to layout cards
*/
@computed get archAngle() {
- return NumCast(this.layoutDoc.card_arch, 90) * (Math.PI / 180) * (this.childCards.length < this._maxRowCount ? this.childCards.length / this._maxRowCount : 1);
+ return NumCast(this.layoutDoc.card_arch, 90) * (Math.PI / 180) * (this.childDocsNoInk.length < this._maxRowCount ? this.childDocsNoInk.length / this._maxRowCount : 1);
}
/**
* Spacing card rows as a percent of Doc size. 100 means rows spread out to fill 100% of the Doc vertically. Default is 60%
@@ -140,7 +118,7 @@ export class CollectionCardView extends CollectionSubView() {
/**
* The child documents to be rendered-- everything other than ink/link docs (which are marks as being svg's)
*/
- @computed get childCards() {
+ @computed get childDocsNoInk() {
return this.childLayoutPairs.filter(pair => !pair.layout.layout_isSvg);
}
@@ -148,7 +126,7 @@ export class CollectionCardView extends CollectionSubView() {
* how much to scale down the contents of the view so that everything will fit
*/
@computed get fitContentScale() {
- const length = Math.min(this.childCards.length, this._maxRowCount);
+ const length = Math.min(this.childDocsNoInk.length, this._maxRowCount);
return (this.childPanelWidth() * length) / this._props.PanelWidth();
}
@@ -164,17 +142,12 @@ export class CollectionCardView extends CollectionSubView() {
return this._props.PanelWidth() - 2 * this.xMargin;
}
- /**
- * When in quiz mode, randomly selects a document
- */
- randomlyChooseDoc = () => (this.layoutDoc._card_curDoc = this.childDocs[Math.floor(Math.random() * this.childDocs.length)]);
-
setHoveredNodeIndex = action((index: number) => {
if (!SnappingManager.IsDragging) this._hoveredNodeIndex = index;
});
isSelected = (doc: Doc) => this._docRefs.get(doc)?.IsSelected;
- childPanelWidth = () => NumCast(this.layoutDoc.childPanelWidth, Math.max(150, this._props.PanelWidth() / (this.childCards.length > this._maxRowCount ? this._maxRowCount : this.childCards.length) / this.nativeScaling));
+ childPanelWidth = () => NumCast(this.layoutDoc.childPanelWidth, Math.max(150, this._props.PanelWidth() / (this.childDocsNoInk.length > this._maxRowCount ? this._maxRowCount : this.childDocsNoInk.length) / this.nativeScaling));
childPanelHeight = () => this._props.PanelHeight() * this.fitContentScale;
onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick);
isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this.isAnyChildContentActive();
@@ -318,10 +291,10 @@ export class CollectionCardView extends CollectionSubView() {
* @returns number of cards in row that contains index
*/
cardsInRowThatIncludesCardIndex = (index: number) => {
- if (this.childCards.length < this._maxRowCount) {
- return this.childCards.length;
+ if (this.childDocsNoInk.length < this._maxRowCount) {
+ return this.childDocsNoInk.length;
}
- const totalCards = this.childCards.length;
+ const totalCards = this.childDocsNoInk.length;
if (index < totalCards - (totalCards % this._maxRowCount)) {
return this._maxRowCount;
}
@@ -385,102 +358,6 @@ export class CollectionCardView extends CollectionSubView() {
: this.translateY(index);
};
- /**
- * A list of the text content of all the child docs. RTF documents will have just their text and pdf documents will have the first 50 words.
- * Image documents are converted to bse64 and gpt generates a description for them. all other documents use their title. This string is
- * inputted into the gpt prompt to sort everything together
- * @returns
- */
- childPairStringList = () => {
- const docToText = (doc: Doc) => {
- switch (doc.type) {
- case DocumentType.PDF: return StrCast(doc.text).split(/\s+/).slice(0, 50).join(' '); // first 50 words of pdf text
- case DocumentType.IMG: return this.getImageDesc(doc);
- case DocumentType.RTF: return StrCast(RTFCast(doc.text).Text);
- default: return StrCast(doc.title);
- } // prettier-ignore
- };
- const docTextPromises = this.childCards
- .map(pair => pair.layout)
- .map(async doc => {
- const docText = (await docToText(doc)) ?? '';
- doc.gptInputText = docText;
- this._textToDoc.set(docText.replace(/\n/g, ' ').trim(), doc);
- return `======${docText.replace(/\n/g, ' ').trim()}======`;
- });
- return Promise.all<string>(docTextPromises);
- };
-
- /**
- * Calls the gpt API to generate descriptions for the images in the view
- * @param image
- * @returns
- */
- getImageDesc = async (image: Doc) => {
- if (StrCast(image.description)) return StrCast(image.description); // Return existing description
- const { href } = (image.data as URLField).url;
- const hrefParts = href.split('.');
- const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`;
- try {
- const hrefBase64 = await imageUrlToBase64(hrefComplete);
- const response = await gptImageLabel(hrefBase64, 'Give three to five labels to describe this image.');
- image[DocData].description = response.trim();
- return response; // Return the response from gptImageLabel
- } catch (error) {
- console.log(error);
- }
- return '';
- };
-
- /**
- * Processes gpt's output depending on the type of question the user asked. Converts gpt's string output to
- * usable code
- * @param gptOutput
- * @param questionType
- * @param tag
- */
- processGptResponse = (gptOutput: string, questionType: GPTTypeStyle, tag?: string) =>
- undoable(() => {
- // Split the string into individual list items
- const listItems = gptOutput.split('======').filter(item => item.trim() !== '');
-
- if (questionType === GPTTypeStyle.Filter) {
- this.childDocs.forEach(d => {
- TagItem.removeTagFromDoc(d, '#chat');
- });
- }
-
- if (questionType === GPTTypeStyle.SortDocs) {
- this.Document[this._props.fieldKey + '_sort'] = docSortings.Chat;
- }
-
- listItems.forEach((item, index) => {
- const normalizedItem = item.trim();
- // find the corresponding Doc in the textToDoc map
- const doc = this._textToDoc.get(normalizedItem);
- if (doc) {
- switch (questionType) {
- case GPTTypeStyle.SortDocs:
- doc.chatIndex = index;
- break;
- case GPTTypeStyle.AssignTags:
- if (tag) {
- const hashTag = tag.startsWith('#') ? tag : '#' + tag[0].toLowerCase() + tag.slice(1);
- const filterTag = Doc.MyFilterHotKeys.map(key => StrCast(key.toolType)).find(key => key.includes(tag)) ?? hashTag;
- TagItem.addTagToDoc(doc, filterTag);
- }
- break;
- case GPTTypeStyle.Filter:
- TagItem.addTagToDoc(doc, '#chat');
- Doc.setDocFilter(this.Document, 'tags', '#chat', 'check');
- break;
- }
- } else {
- console.warn(`No matching document found for item: ${normalizedItem}`);
- }
- });
- }, '')();
-
childScreenToLocal = computedFn((doc: Doc, index: number, isSelected: boolean) => () => {
// need to explicitly trigger an invalidation since we're reading everything from the Dom
this._forceChildXf;
@@ -620,7 +497,7 @@ export class CollectionCardView extends CollectionSubView() {
curDoc = () => DocCast(this.layoutDoc._card_curDoc);
render() {
- const fitContentScale = this.childCards.length === 0 ? 1 : this.fitContentScale;
+ const fitContentScale = this.childDocsNoInk.length === 0 ? 1 : this.fitContentScale;
return (
<div
className="collectionCardView-outer"
@@ -652,7 +529,7 @@ export class CollectionCardView extends CollectionSubView() {
<div
className="collectionCardView-flashcardUI"
style={{
- pointerEvents: this.childCards.length === 0 ? undefined : 'none',
+ pointerEvents: this.childDocsNoInk.length === 0 ? undefined : 'none',
height: `${100 / this.nativeScaling / fitContentScale}%`,
width: `${100 / this.nativeScaling / fitContentScale}%`,
transform: `scale(${this.nativeScaling * fitContentScale})`,
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index 5e99bec39..954f9aa4c 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -121,6 +121,7 @@ export function CollectionSubView<X>() {
return this.dataDoc[this._props.fieldKey]; // this used to be 'layoutDoc', but then template fields will get ignored since the template is not a proto of the layout. hopefully nothing depending on the previous code.
}
+ hasChildDocs = () => this.childLayoutPairs.map(pair => pair.layout);
@computed get childLayoutPairs(): { layout: Doc; data: Doc }[] {
const { Document, TemplateDataDocument } = this._props;
const validPairs = this.childDocs
diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx
index 6f0833a22..a4900e9d7 100644
--- a/src/client/views/collections/CollectionView.tsx
+++ b/src/client/views/collections/CollectionView.tsx
@@ -89,6 +89,8 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr
TraceMobx();
if (type === undefined) return null;
switch (type) {
+ default:
+ case CollectionViewType.Freeform: return <CollectionFreeFormView key="collview" {...props} />;
case CollectionViewType.Schema: return <CollectionSchemaView key="collview" {...props} />;
case CollectionViewType.Calendar: return <CalendarBox key="collview" {...props} />;
case CollectionViewType.Docking: return <CollectionDockingView key="collview" {...props} />;
@@ -105,8 +107,6 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr
case CollectionViewType.Time: return <CollectionTimeView key="collview" {...props} />;
case CollectionViewType.Grid: return <CollectionGridView key="collview" {...props} />;
case CollectionViewType.Card: return <CollectionCardView key="collview" {...props} />;
- case CollectionViewType.Freeform:
- default: return <CollectionFreeFormView key="collview" {...props} />;
}
};
diff --git a/src/client/views/nodes/chatbot/tools/CreateDocumentTool.ts b/src/client/views/nodes/chatbot/tools/CreateDocumentTool.ts
index 6dc36b0d1..284879a4a 100644
--- a/src/client/views/nodes/chatbot/tools/CreateDocumentTool.ts
+++ b/src/client/views/nodes/chatbot/tools/CreateDocumentTool.ts
@@ -263,79 +263,79 @@ const standardOptions = ['title', 'backgroundColor'];
* Description of document options and data field for each type.
*/
const documentTypesInfo: { [key in supportedDocTypes]: { options: string[]; dataDescription: string } } = {
- [supportedDocTypes.comparison]: {
+ comparison: {
options: [...standardOptions, 'fontColor', 'text_align'],
dataDescription: 'an array of two documents of any kind that can be compared.',
},
- [supportedDocTypes.deck]: {
+ deck: {
options: [...standardOptions, 'fontColor', 'text_align'],
dataDescription: 'an array of flashcard docs',
},
- [supportedDocTypes.flashcard]: {
+ flashcard: {
options: [...standardOptions, 'fontColor', 'text_align'],
dataDescription: 'an array of two strings. the first string contains a question, and the second string contains an answer',
},
- [supportedDocTypes.text]: {
+ text: {
options: [...standardOptions, 'fontColor', 'text_align'],
dataDescription: 'The text content of the document.',
},
- [supportedDocTypes.web]: {
+ web: {
options: [],
dataDescription: 'A URL to a webpage. Example: https://en.wikipedia.org/wiki/Brown_University',
},
- [supportedDocTypes.html]: {
+ html: {
options: [],
dataDescription: 'The HTML-formatted text content of the document.',
},
- [supportedDocTypes.equation]: {
+ equation: {
options: [...standardOptions, 'fontColor'],
dataDescription: 'The equation content represented as a MathML string.',
},
- [supportedDocTypes.functionplot]: {
+ functionplot: {
options: [...standardOptions, 'function_definition'],
dataDescription: 'The function definition(s) for plotting. Provide as a string or array of function definitions.',
},
- [supportedDocTypes.dataviz]: {
+ dataviz: {
options: [...standardOptions, 'chartType'],
dataDescription: 'A string of comma-separated values representing the CSV data.',
},
- [supportedDocTypes.notetaking]: {
+ notetaking: {
options: standardOptions,
dataDescription: 'An array of related text documents with small amounts of text.',
},
- [supportedDocTypes.rtf]: {
+ rtf: {
options: standardOptions,
dataDescription: 'The rich text content in RTF format.',
},
- [supportedDocTypes.image]: {
+ image: {
options: standardOptions,
dataDescription: `A url string that must end with '.png', '.jpeg', '.gif', or '.jpg'`,
},
- [supportedDocTypes.pdf]: {
+ pdf: {
options: standardOptions,
dataDescription: 'the pdf content as a PDF file url.',
},
- [supportedDocTypes.audio]: {
+ audio: {
options: standardOptions,
dataDescription: 'The audio content as a file url.',
},
- [supportedDocTypes.video]: {
+ video: {
options: standardOptions,
dataDescription: 'The video content as a file url.',
},
- [supportedDocTypes.message]: {
+ message: {
options: standardOptions,
dataDescription: 'The message content of the document.',
},
- [supportedDocTypes.diagram]: {
+ diagram: {
options: standardOptions,
dataDescription: 'diagram content as a text string in Mermaid format.',
},
- [supportedDocTypes.script]: {
+ script: {
options: standardOptions,
dataDescription: 'The compilable JavaScript code. Use this for creating scripts.',
},
- [supportedDocTypes.collection]: {
+ collection: {
options: [...standardOptions, 'type_collection'],
dataDescription: 'A collection of Docs represented as an array.',
},
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 2e14fb1d9..6960247e9 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -1063,9 +1063,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
generateImage = async () => {
GPTPopup.Instance?.setTextAnchor(this.getAnchor(false));
- GPTPopup.Instance?.setImgTargetDoc(this.Document);
- GPTPopup.Instance.addToCollection = this._props.addDocument;
- GPTPopup.Instance.generateImage((this.dataDoc.text as RichTextField)?.Text);
+ GPTPopup.Instance.generateImage((this.dataDoc.text as RichTextField)?.Text, this.Document, this._props.addDocument);
};
breakupDictation = () => {
diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
index 0b1ee78e3..f09d786d0 100644
--- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx
+++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
@@ -1,6 +1,6 @@
import { Button, IconButton, Toggle, ToggleType, Type } from '@dash/components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { action, makeObservable, observable } from 'mobx';
+import { action, makeObservable, observable, reaction } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
import { CgClose, CgCornerUpLeft } from 'react-icons/cg';
@@ -10,12 +10,15 @@ import { ClientUtils } from '../../../../ClientUtils';
import { Doc } from '../../../../fields/Doc';
import { NumCast, StrCast } from '../../../../fields/Types';
import { Networking } from '../../../Network';
-import { GPTCallType, GPTTypeStyle, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT';
+import { DescriptionSeperator, DocSeperator, GPTCallType, GPTTypeStyle, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT';
import { DocUtils } from '../../../documents/DocUtils';
import { Docs } from '../../../documents/Documents';
import { SettingsManager } from '../../../util/SettingsManager';
import { SnappingManager } from '../../../util/SnappingManager';
+import { undoable } from '../../../util/UndoManager';
import { ObservableReactComponent } from '../../ObservableReactComponent';
+import { TagItem } from '../../TagsView';
+import { docSortings } from '../../collections/CollectionSubView';
import { DocumentView } from '../../nodes/DocumentView';
import { AnchorMenu } from '../AnchorMenu';
import './GPTPopup.scss';
@@ -29,61 +32,71 @@ export enum GPTPopupMode {
QUIZ_RESPONSE, // user definitions or explanations to be evaluated by GPT
}
-export enum GPTQuizType {
- CURRENT = 0,
- CHOOSE = 1,
- MULTIPLE = 2,
-}
-
@observer
export class GPTPopup extends ObservableReactComponent<object> {
// eslint-disable-next-line no-use-before-define
static Instance: GPTPopup;
- private _retrieveDocDescriptions: (() => Promise<void>) | null = null;
private _messagesEndRef: React.RefObject<HTMLDivElement>;
private _correlatedColumns: string[] = [];
private _dataChatPrompt: string | undefined = undefined;
private _imgTargetDoc: Doc | undefined;
private _textAnchor: Doc | undefined;
private _dataJson: string = '';
- private _documentDescriptions: string = '';
+ private _documentDescriptions: Promise<string> | undefined; // a cache of the descriptions of all docs in the selected collection. makes it more efficient when asking GPT multiple questions about the collection.
private _sidebarFieldKey: string = '';
private _textToSummarize: string = '';
private _imageDescription: string = '';
+ private _textToDocMap = new Map<string, Doc>(); // when GPT answers with a doc's content, this helps us find the Doc
+ private _addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined;
+
+ constructor(props: object) {
+ super(props);
+ makeObservable(this);
+ GPTPopup.Instance = this;
+ this._messagesEndRef = React.createRef();
+ }
+ public addDoc: ((doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean) | undefined;
+ public createFilteredDoc: (axes?: string[]) => boolean = () => false;
public setSidebarFieldKey = (id: string) => (this._sidebarFieldKey = id);
- public setDocumentDescriptions = (t: string) => (this._documentDescriptions = t);
public setImgTargetDoc = (anchor: Doc) => (this._imgTargetDoc = anchor);
public setTextAnchor = (anchor: Doc) => (this._textAnchor = anchor);
- public onGptResponse?: (sortResult: string, questionType: GPTTypeStyle, tag?: string) => void;
- public onQuizRandom?: () => void;
public setDataJson = (text: string) => {
if (text === '') this._dataChatPrompt = '';
this._dataJson = text;
};
- constructor(props: object) {
- super(props);
- makeObservable(this);
- GPTPopup.Instance = this;
- this._messagesEndRef = React.createRef();
+ componentDidUpdate() {
+ this._gptProcessing && this.setStopAnimatingResponse(false);
+ }
+ componentDidMount(): void {
+ reaction(
+ () => DocumentView.Selected().lastElement(),
+ selDoc => {
+ const hasChildDocs = selDoc?.ComponentView?.hasChildDocs;
+ if (hasChildDocs) {
+ this._textToDocMap.clear();
+ this.setCollectionContext(selDoc.Document);
+ this.onGptResponse = (sortResult: string, questionType: GPTTypeStyle, tag?: string) => this.processGptResponse(selDoc, this._textToDocMap, sortResult, questionType, tag);
+ this.onQuizRandom = () => this.randomlyChooseDoc(selDoc.Document, hasChildDocs());
+ this._documentDescriptions = Promise.all(hasChildDocs().map(doc =>
+ Doc.getDescription(doc).then(text => this._textToDocMap.set(text, doc) && `${DescriptionSeperator}${text}${DescriptionSeperator}`)
+ )).then(docDescriptions => docDescriptions.join()); // prettier-ignore
+ }
+ },
+ { fireImmediately: true }
+ );
}
-
- componentDidUpdate = () => this._gptProcessing && this.setStopAnimatingResponse(false);
@observable private _conversationArray: string[] = ['Hi! In this pop up, you can ask ChatGPT questions about your documents and filter / sort them. '];
@observable private _chatEnabled: boolean = false;
@action private setChatEnabled = (start: boolean) => (this._chatEnabled = start);
- @observable public Visible: boolean = false;
- @action public setVisible = (vis: boolean) => (this.Visible = vis);
@observable private _gptProcessing: boolean = false;
- @action public setGptProcessing = (loading: boolean) => (this._gptProcessing = loading);
+ @action private setGptProcessing = (loading: boolean) => (this._gptProcessing = loading);
@observable private _responseText: string = '';
- @action public setResponseText = (text: string) => (this._responseText = text);
+ @action private setResponseText = (text: string) => (this._responseText = text);
@observable private _imgUrls: string[][] = [];
- @action public setImgUrls = (imgs: string[][]) => (this._imgUrls = imgs);
- @observable private _mode: GPTPopupMode = GPTPopupMode.SUMMARY;
- @action public setMode = (mode: GPTPopupMode) => (this._mode = mode);
+ @action private setImgUrls = (imgs: string[][]) => (this._imgUrls = imgs);
@observable private _collectionContext: Doc | undefined = undefined;
@action setCollectionContext = (doc: Doc | undefined) => (this._collectionContext = doc);
@observable private _userPrompt: string = '';
@@ -91,23 +104,68 @@ export class GPTPopup extends ObservableReactComponent<object> {
@observable private _quizAnswer: string = '';
@action setQuizAnswer = (e: React.ChangeEvent<HTMLInputElement>) => (this._quizAnswer = e.target.value);
@observable private _stopAnimatingResponse: boolean = false;
- @action public setStopAnimatingResponse = (done: boolean) => (this._stopAnimatingResponse = done);
+ @action private setStopAnimatingResponse = (done: boolean) => (this._stopAnimatingResponse = done);
+
+ @observable private _mode: GPTPopupMode = GPTPopupMode.SUMMARY;
+ @action public setMode = (mode: GPTPopupMode) => (this._mode = mode);
+ @observable public Visible: boolean = false;
+ @action public setVisible = (vis: boolean) => (this.Visible = vis);
+
+ onQuizRandom?: () => void;
+ onGptResponse?: (sortResult: string, questionType: GPTTypeStyle, tag?: string) => void;
+ questionTypeNumberToStyle = (questionType: string) => +questionType.split(' ')[0][0];
/**
- * sets callback needed to retrieve an updated description of the collection's documents
- * @param collectionDoc - the collection doc context for the GPT prompts
- * @param callback - function to retrieve document descriptions
+ * Processes gpt's output depending on the type of question the user asked. Converts gpt's string output to
+ * usable code
+ * @param gptOutput
+ * @param questionType
+ * @param tag
*/
- public setRetrieveDocDescriptionsCallback(collectionDoc: Doc | undefined, callback: null | (() => Promise<void>)) {
- this.setCollectionContext(collectionDoc);
- this._retrieveDocDescriptions = callback;
- }
+ processGptResponse = (docView: DocumentView, textToDocMap: Map<string, Doc>, gptOutput: string, questionType: GPTTypeStyle, tag?: string) =>
+ undoable(() => {
+ // Split the string into individual list items
+ const listItems = gptOutput.split('======').filter(item => item.trim() !== '');
- public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false;
- public createFilteredDoc: (axes?: string[]) => boolean = () => false;
- public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined;
- public questionTypeNumberToStyle = (questionType: string) => +questionType.split(' ')[0][0];
+ if (questionType === GPTTypeStyle.Filter) {
+ docView.ComponentView?.hasChildDocs?.().forEach(d => TagItem.removeTagFromDoc(d, '#chat'));
+ }
+
+ if (questionType === GPTTypeStyle.SortDocs) {
+ docView.Document[docView.ComponentView?.fieldKey + '_sort'] = docSortings.Chat;
+ }
+ listItems.forEach((item, index) => {
+ const normalizedItem = item.replace(/\n/g, ' ').trim();
+ // find the corresponding Doc in the textToDoc map
+ const doc = textToDocMap.get(normalizedItem);
+ if (doc) {
+ switch (questionType) {
+ case GPTTypeStyle.SortDocs:
+ doc.chatIndex = index;
+ break;
+ case GPTTypeStyle.AssignTags:
+ if (tag) {
+ const hashTag = tag.startsWith('#') ? tag : '#' + tag[0].toLowerCase() + tag.slice(1);
+ const filterTag = Doc.MyFilterHotKeys.map(key => StrCast(key.toolType)).find(key => key.includes(tag)) ?? hashTag;
+ TagItem.addTagToDoc(doc, filterTag);
+ }
+ break;
+ case GPTTypeStyle.Filter:
+ TagItem.addTagToDoc(doc, '#chat');
+ Doc.setDocFilter(docView.Document, 'tags', '#chat', 'check');
+ break;
+ }
+ } else {
+ console.warn(`No matching document found for item: ${normalizedItem}`);
+ }
+ });
+ }, '')();
+
+ /**
+ * When in quiz mode, randomly selects a document
+ */
+ randomlyChooseDoc = (doc: Doc, childDocs: Doc[]) => DocumentView.getDocumentView(childDocs[Math.floor(Math.random() * childDocs.length)])?.select(false);
/**
* Generates a rubric for evaluating the user's description of the document's text
* @param doc the doc the user is providing info about
@@ -116,9 +174,11 @@ export class GPTPopup extends ObservableReactComponent<object> {
generateRubric = (doc: Doc) =>
StrCast(doc.gptRubric)
? Promise.resolve(StrCast(doc.gptRubric))
- : gptAPICall(StrCast(doc.gptInputText), GPTCallType.RUBRIC)
- .then(res => (doc.gptRubric = res))
- .catch(err => console.error('GPT call failed', err));
+ : Doc.getDescription(doc).then(desc =>
+ gptAPICall(desc, GPTCallType.RUBRIC)
+ .then(res => (doc.gptRubric = res))
+ .catch(err => console.error('GPT call failed', err))
+ );
/**
* When the cards are in quiz mode in the card view, allows gpt to determine whether the user's answer was correct
@@ -128,17 +188,18 @@ export class GPTPopup extends ObservableReactComponent<object> {
*/
generateQuizAnswerAnalysis = (doc: Doc, quizAnswer: string) =>
this.generateRubric(doc).then(() =>
- gptAPICall(
- `Question: ${StrCast(doc.gptInputText)};
- UserAnswer: ${quizAnswer};
- Rubric: ${StrCast(doc.gptRubric)}`,
- GPTCallType.QUIZ
- ).then(res => {
- this._conversationArray.push(res || 'GPT provided no answer');
- this.onQuizRandom?.();
- })
- .catch(err => console.error('GPT call failed', err))
- ) // prettier-ignore
+ Doc.getDescription(doc).then(desc =>
+ gptAPICall(
+ `Question: ${desc};
+ UserAnswer: ${quizAnswer};
+ Rubric: ${StrCast(doc.gptRubric)}`,
+ GPTCallType.QUIZ
+ ).then(res => {
+ this._conversationArray.push(res || 'GPT provided no answer');
+ this.onQuizRandom?.();
+ })
+ .catch(err => console.error('GPT call failed', err))
+ )) // prettier-ignore
/**
* Generates a response to the user's question about the docs in the collection.
@@ -146,32 +207,33 @@ export class GPTPopup extends ObservableReactComponent<object> {
* @param userPrompt the user's input that chat will respond to
*/
generateUserPromptResponse = (userPrompt: string) =>
- (this._retrieveDocDescriptions ?? Promise.resolve)().then(() =>
- gptAPICall(userPrompt, GPTCallType.TYPE).then(questionType =>
- (() => {
- switch (this.questionTypeNumberToStyle(questionType)) {
- case GPTTypeStyle.AssignTags:
- case GPTTypeStyle.Filter: return gptAPICall(this._documentDescriptions, GPTCallType.SUBSET, userPrompt);
- case GPTTypeStyle.SortDocs: return gptAPICall(this._documentDescriptions, GPTCallType.SORT, userPrompt);
- default: return gptAPICall(StrCast(DocumentView.SelectedDocs().lastElement()?.gptInputText), GPTCallType.INFO, userPrompt);
- } // prettier-ignore
- })().then(
- action(res => {
- // Trigger the callback with the result
- this.onGptResponse?.(res || 'Something went wrong :(', this.questionTypeNumberToStyle(questionType), questionType.split(' ').slice(1).join(' '));
- this._conversationArray.push(
- ![GPTTypeStyle.GeneralInfo, GPTTypeStyle.DocInfo].includes(this.questionTypeNumberToStyle(questionType))?
- // Extract explanation surrounded by ------ at the top or both at the top and bottom
- (res.match(/------\s*([\s\S]*?)\s*(?:------|$)/) ?? [])[1]?.trim() ?? 'No explanation found' : res);
- })
- ).catch(err => console.log(err))
+ gptAPICall(userPrompt, GPTCallType.TYPE).then(questionType =>
+ (async () => {
+ switch (this.questionTypeNumberToStyle(questionType)) {
+ case GPTTypeStyle.AssignTags:
+ case GPTTypeStyle.Filter: return this._documentDescriptions?.then(descs => gptAPICall(descs, GPTCallType.SUBSET, userPrompt)) ?? "";
+ case GPTTypeStyle.SortDocs: return this._documentDescriptions?.then(descs => gptAPICall(descs, GPTCallType.SORT, userPrompt)) ?? "";
+ default: return Doc.getDescription(DocumentView.SelectedDocs().lastElement()).then(desc => gptAPICall(desc, GPTCallType.INFO, userPrompt));
+ } // prettier-ignore
+ })().then(
+ action(res => {
+ // Trigger the callback with the result
+ this.onGptResponse?.(res || 'Something went wrong :(', this.questionTypeNumberToStyle(questionType), questionType.split(' ').slice(1).join(' '));
+ this._conversationArray.push(
+ [GPTTypeStyle.GeneralInfo, GPTTypeStyle.DocInfo].includes(this.questionTypeNumberToStyle(questionType)) ? res:
+ // Extract explanation surrounded by the DocSeperator string (defined in GPT.ts) at the top or both at the top and bottom
+ (res.match(new RegExp(`${DocSeperator}\\s*([\\s\\S]*?)\\s*(?:${DocSeperator}|$)`)) ?? [])[1]?.trim() ?? 'No explanation found'
+ );
+ })
).catch(err => console.log(err))
- ); // prettier-ignore
+ ).catch(err => console.log(err)); // prettier-ignore
/**
* Generates a Dalle image and uploads it to the server.
*/
- generateImage = (imgDesc: string) => {
+ generateImage = (imgDesc: string, imgTarget: Doc, addToCollection?: (doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) => {
+ this._imgTargetDoc = imgTarget;
+ this.addDoc = addToCollection;
this.setImgUrls([]);
this.setMode(GPTPopupMode.IMAGE);
this.setVisible(true);
@@ -237,7 +299,7 @@ export class GPTPopup extends ObservableReactComponent<object> {
_layout_fitWidth: true,
_layout_autoHeight: true,
});
- this.addDoc(newDoc, this._sidebarFieldKey);
+ this.addDoc?.(newDoc, this._sidebarFieldKey);
const anchor = AnchorMenu.Instance?.GetAnchor(undefined, false);
if (anchor) {
DocUtils.MakeLink(newDoc, anchor, {
@@ -270,7 +332,7 @@ export class GPTPopup extends ObservableReactComponent<object> {
newDoc.overlayY = NumCast(textAnchor.y) + NumCast(textAnchor._height);
Doc.AddToMyOverlay(newDoc);
} else {
- this.addToCollection?.(newDoc);
+ this.addDoc?.(newDoc);
}
// Create link between prompt and image
DocUtils.MakeLink(textAnchor, newDoc, { link_relationship: 'Image Prompt' });
@@ -282,8 +344,8 @@ export class GPTPopup extends ObservableReactComponent<object> {
gptMenu = () => (
<div className="btns-wrapper-gpt">
<Button
- tooltip="Have ChatGPT sort, tag, define, or filter your documents for you!"
- text="Modify/Sort Cards!"
+ tooltip="Ask GPT to sort, tag, define, or filter your Docs!"
+ text="Ask GPT"
onClick={() => this.setMode(GPTPopupMode.USER_PROMPT)}
color={StrCast(Doc.UserDoc().userVariantColor)}
type={Type.TERT}
@@ -297,8 +359,8 @@ export class GPTPopup extends ObservableReactComponent<object> {
}}
/>
<Button
- tooltip="Test your knowledge with ChatGPT!"
- text="Quiz Cards!"
+ tooltip="Test your knowledge by verifying answers with ChatGPT"
+ text="Take Quiz"
onClick={() => {
this._conversationArray = ['Define the selected card!'];
this.setMode(GPTPopupMode.QUIZ_RESPONSE);
@@ -386,7 +448,14 @@ export class GPTPopup extends ObservableReactComponent<object> {
</div>
))}
</div>
- {this._gptProcessing ? null : <IconButton tooltip="Generate Again" onClick={() => this.generateImage(this._imageDescription)} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(Doc.UserDoc().userVariantColor)} />}
+ {this._gptProcessing ? null : (
+ <IconButton
+ tooltip="Generate Again"
+ onClick={() => this._imgTargetDoc && this.generateImage(this._imageDescription, this._imgTargetDoc, this._addToCollection)}
+ icon={<FontAwesomeIcon icon="redo-alt" size="lg" />}
+ color={StrCast(Doc.UserDoc().userVariantColor)}
+ />
+ )}
</div>
);
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index e62ca4bb8..950d9047c 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -7,7 +7,7 @@ import { CollectionViewType, DocumentType } from '../client/documents/DocumentTy
import { scriptingGlobal, ScriptingGlobals } from '../client/util/ScriptingGlobals';
import { afterDocDeserialize, autoObject, Deserializable, SerializationHelper } from '../client/util/SerializationHelper';
import { undoable, UndoManager } from '../client/util/UndoManager';
-import { ClientUtils, incrementTitleCopy } from '../ClientUtils';
+import { ClientUtils, imageUrlToBase64, incrementTitleCopy } from '../ClientUtils';
import {
AclAdmin, AclAugment, AclEdit, AclPrivate, AclReadonly, Animation, AudioPlay, Brushed, CachedUpdates, DirectLinks,
DocAcl, DocCss, DocData, DocLayout, DocViews, FieldKeys, FieldTuples, ForceServerWrite, Height, Highlight,
@@ -22,8 +22,9 @@ import { FieldId, RefField } from './RefField';
import { RichTextField } from './RichTextField';
import { listSpec } from './Schema';
import { ComputedField, ScriptField } from './ScriptField';
-import { BoolCast, Cast, DocCast, FieldValue, NumCast, StrCast, ToConstructor, toList } from './Types';
+import { BoolCast, Cast, DocCast, FieldValue, ImageCastWithSuffix, NumCast, RTFCast, StrCast, ToConstructor, toList } from './Types';
import { containedFieldChangedHandler, deleteProperty, GetEffectiveAcl, getField, getter, makeEditable, makeReadOnly, setter, SharingPermissions } from './util';
+import { gptImageLabel } from '../client/apis/gpt/GPT';
export let ObjGetRefField: (id: string, force?: boolean) => Promise<Doc | undefined>;
export let ObjGetRefFields: (ids: string[]) => Promise<Map<string, Doc | undefined>>;
@@ -1467,6 +1468,25 @@ export namespace Doc {
});
}
+ /**
+ * text description of a Doc. RTF documents will have just their text and pdf documents will have the first 50 words.
+ * Image documents are converted to bse64 and gpt generates a description for them. all other documents use their title.
+ * @param doc
+ * @returns
+ */
+ export function getDescription(doc: Doc) {
+ const curDescription = StrCast(doc[DocData][Doc.LayoutFieldKey(doc) + '_description']);
+ const docText = (async (tdoc:Doc) => {
+ switch (tdoc.type) {
+ case DocumentType.PDF: return curDescription || StrCast(tdoc.text).split(/\s+/).slice(0, 50).join(' '); // first 50 words of pdf text
+ case DocumentType.IMG: return curDescription || imageUrlToBase64(ImageCastWithSuffix(Doc.LayoutField(tdoc), '_o') ?? '')
+ .then(hrefBase64 => gptImageLabel(hrefBase64, 'Give three to five labels to describe this image.'));
+ case DocumentType.RTF: return RTFCast(tdoc[Doc.LayoutFieldKey(tdoc)]).Text;
+ default: return StrCast(tdoc.title);
+ }}); // prettier-ignore
+ return docText(doc).then(text => (doc[DocData][Doc.LayoutFieldKey(doc) + '_description'] = text));
+ }
+
// prettier-ignore
export function toIcon(doc?: Doc, isOpen?: Opt<boolean>) {
if (isOpen) return doc?.isFolder ? 'chevron-down' : 'folder-open';
diff --git a/src/fields/Types.ts b/src/fields/Types.ts
index e19673665..474882959 100644
--- a/src/fields/Types.ts
+++ b/src/fields/Types.ts
@@ -134,6 +134,10 @@ export function PDFCast(field: FieldResult, defaultVal: PdfField | null = null)
export function ImageCast(field: FieldResult, defaultVal: ImageField | null = null) {
return Cast(field, ImageField, defaultVal);
}
+export function ImageCastWithSuffix(field: FieldResult, suffix: string, defaultVal: ImageField | null = null) {
+ const href = ImageCast(field, defaultVal)?.url.href;
+ return href ? `${href.split('.')[0]}${suffix}.${href.split('.')[1]}` : null;
+}
export function FieldValue<T extends FieldType, U extends WithoutList<T>>(field: FieldResult<T>, defaultValue: U): WithoutList<T>;
// eslint-disable-next-line no-redeclare