diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/client/apis/gpt/GPT.ts | 44 | ||||
-rw-r--r-- | src/client/views/ViewBoxInterface.ts | 1 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx | 8 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx | 2 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/MarqueeView.tsx | 35 | ||||
-rw-r--r-- | src/client/views/nodes/ImageBox.tsx | 53 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBox.tsx | 18 | ||||
-rw-r--r-- | src/client/views/nodes/scrapbook/ScrapbookBox.tsx | 341 | ||||
-rw-r--r-- | src/client/views/nodes/scrapbook/ScrapbookPreset.tsx | 146 | ||||
-rw-r--r-- | src/client/views/nodes/scrapbook/ScrapbookSettingsPanel.tsx | 60 | ||||
-rw-r--r-- | src/client/views/search/FaceRecognitionHandler.tsx | 16 |
11 files changed, 699 insertions, 25 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index 9cb47995c..693b4f901 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -11,6 +11,10 @@ export enum GPTDocCommand { export const DescriptionSeperator = '======'; export const DocSeperator = '------'; +export enum TextClassifications { + Title = 'title', //a few words + Caption = 'caption', //few sentences + LengthyDescription = 'lengthy description' } enum GPTCallType { SUMMARY = 'summary', @@ -36,6 +40,7 @@ enum GPTCallType { SUBSETDOCS = 'subset_docs', // select a subset of documents based on their descriptions DOCINFO = 'doc_info', // provide information about a document SORTDOCS = 'sort_docs', + CLASSIFYTEXT = 'classify_text', // classify text into one of the three categories: title, caption, lengthy description } type GPTCallOpts = { @@ -48,6 +53,23 @@ type 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.' }, + + + sort_docs: { + 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 '${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`, + }, + + edit: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' }, stack: { model: 'gpt-4o', @@ -69,17 +91,14 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { temp: 0.5, prompt: "You are a helpful resarch assistant. Analyze the user's data to find meaningful patterns and/or correlation. Please only return a JSON with a correlation column 1 propert, a correlation column 2 property, and an analysis property. ", }, - sort_docs: { + //new + classify_text: { 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 '${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`, + prompt: `Based on the content of the the text, classify it into the + most appropriate category: '${TextClassifications.Title}', '${TextClassifications.Caption}', or '${TextClassifications.LengthyDescription}'. Output exclusively the classification in your response. + ` }, describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' }, flashcard: { @@ -107,6 +126,7 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { temp: 0.5, prompt: 'You will be given a list of field descriptions for one or more templates in the format {field #0: “description”}{field #1: “description”}{...}, and a list of column descriptions in the format {“title”: “description”}{...}. Your job is to match columns with fields based on their descriptions. Your output should be in the following JSON format: {“template_title”:{“#”: “title”, “#”: “title”, “#”: “title” …}, “template_title”:{“#”: “title”, “#”: “title”, “#”: “title” …}} where “template_title” is the templates title as specified in the description provided, # represents the field # and “title” the title of the column assigned to it. A filled out example might look like {“fivefield2”:{“0”:”Name”, “1”:”Image”, “2”:”Caption”, “3”:”Position”, “4”:”Stats”}, “fivefield3”:{0:”Image”, 1:”Name”, 2:”Caption”, 3:”Stats”, 4:”Position”}. Include one object for each template. IT IS VERY IMPORTANT THAT YOU ONLY INCLUDE TEXT IN THE FORMAT ABOVE, WITH NO ADDITIONS WHATSOEVER. Do not include extraneous ‘#’ characters, ‘column’ for columns, or ‘template’ for templates: ONLY THE TITLES AND NUMBERS. There should never be one column assigned to more than one field (ie. if the “name” column is assigned to field 1, it can’t be assigned to any other fields) . Do this for each template whose fields are described. The descriptions are as follows:', }, + vizsum: { model: 'gpt-4-turbo', maxTokens: 512, @@ -138,6 +158,14 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { temp: 0.5, prompt: 'You will be coloring drawings. You will be given what the drawing is, then a list of descriptions for parts of the drawing. Based on each description, respond with the stroke and fill color that it should be. Follow the rules: 1. Avoid using black for stroke color 2. Make the stroke color 1-3 shades darker than the fill color 3. Use the same colors when possible. Format as {#abcdef #abcdef}, making sure theres a color for each description, and do not include any additional text.', }, + + scrapbook: { + model: 'gpt-4-turbo', + maxTokens: 512, + temp: 0.5, + prompt: 'You will be given a list of field descriptions for one or more templates in the format {field #0: “description”}{field #1: “description”}{...}, and a list of column descriptions in the format {“title”: “description”}{...}. Your job is to match columns with fields based on their descriptions. Your output should be in the following JSON format: {“template_title”:{“#”: “title”, “#”: “title”, “#”: “title” …}, “template_title”:{“#”: “title”, “#”: “title”, “#”: “title” …}} where “template_title” is the templates title as specified in the description provided, # represents the field # and “title” the title of the column assigned to it. A filled out example might look like {“fivefield2”:{“0”:”Name”, “1”:”Image”, “2”:”Caption”, “3”:”Position”, “4”:”Stats”}, “fivefield3”:{0:”Image”, 1:”Name”, 2:”Caption”, 3:”Stats”, 4:”Position”}. Include one object for each template. IT IS VERY IMPORTANT THAT YOU ONLY INCLUDE TEXT IN THE FORMAT ABOVE, WITH NO ADDITIONS WHATSOEVER. Do not include extraneous ‘#’ characters, ‘column’ for columns, or ‘template’ for templates: ONLY THE TITLES AND NUMBERS. There should never be one column assigned to more than one field (ie. if the “name” column is assigned to field 1, it can’t be assigned to any other fields) . Do this for each template whose fields are described. The descriptions are as follows:', + }, + command_type: { model: 'gpt-4-turbo', maxTokens: 1024, diff --git a/src/client/views/ViewBoxInterface.ts b/src/client/views/ViewBoxInterface.ts index d8dab8e89..b532dfe35 100644 --- a/src/client/views/ViewBoxInterface.ts +++ b/src/client/views/ViewBoxInterface.ts @@ -24,6 +24,7 @@ export abstract class ViewBoxInterface<P> extends ObservableReactComponent<React promoteCollection?: () => void; // moves contents of collection to parent hasChildDocs?: () => Doc[]; docEditorView?: () => void; + autoTag?: () => void; // auto tag the document showSmartDraw?: (x: number, y: number, regenerate?: boolean) => void; 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) diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx index ff9fb14e7..038b1c6f9 100644 --- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx +++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx @@ -158,17 +158,19 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { this._currentLabel = e.target.value; }); - classifyImagesInBox = async () => { + classifyImagesInBox = async (selectedImages? : Doc[], prompt? : string) => { this.startLoading(); + alert('Classifying images...'); + selectedImages ??= this._selectedImages; // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them. - const imageInfos = this._selectedImages.map(async doc => { + const imageInfos = selectedImages.map(async doc => { if (!doc.$tags_chat) { const url = ImageCastWithSuffix(doc[Doc.LayoutDataKey(doc)], '_o') ?? ''; return imageUrlToBase64(url).then(hrefBase64 => !hrefBase64 ? undefined : - gptImageLabel(hrefBase64,'Give three labels to describe this image.').then(labels => + gptImageLabel(hrefBase64, prompt ?? 'Give three labels to describe this image.').then(labels => ({ doc, labels }))) ; // prettier-ignore } }); diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx index abd828945..2ec59e5d5 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx @@ -16,6 +16,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { public createCollection: (e: KeyboardEvent | React.PointerEvent | undefined, group?: boolean, selection?: Doc[]) => Doc | void = unimplementedFunction; public delete: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public summarize: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; + public generateScrapbook: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public showMarquee: () => void = unimplementedFunction; public hideMarquee: () => void = unimplementedFunction; public pinWithView: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; @@ -38,6 +39,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { <IconButton tooltip="Create a Collection" onPointerDown={this.createCollection} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> <IconButton tooltip="Create a Grouping" onPointerDown={e => this.createCollection(e, true)} icon={<FontAwesomeIcon icon="layer-group" />} color={this.userColor} /> <IconButton tooltip="Summarize Documents" onPointerDown={this.summarize} icon={<FontAwesomeIcon icon="compress-arrows-alt" />} color={this.userColor} /> + <IconButton tooltip="Generate Scrapbook" onPointerDown={this.generateScrapbook} icon={<FontAwesomeIcon icon="palette" />} color={this.userColor} /> <IconButton tooltip="Delete Documents" onPointerDown={this.delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={this.userColor} /> <IconButton tooltip="Pin selected region" onPointerDown={this.pinWithView} icon={<FontAwesomeIcon icon="map-pin" />} color={this.userColor} /> <IconButton tooltip="Classify and Sort Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 3cc7c0f2d..f5e699d3e 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -275,6 +275,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps MarqueeOptionsMenu.Instance.createCollection = this.collection; MarqueeOptionsMenu.Instance.delete = this.delete; MarqueeOptionsMenu.Instance.summarize = this.summary; + MarqueeOptionsMenu.Instance.generateScrapbook = this.generateScrapbook; MarqueeOptionsMenu.Instance.showMarquee = this.showMarquee; MarqueeOptionsMenu.Instance.hideMarquee = this.hideMarquee; MarqueeOptionsMenu.Instance.jumpTo(e.clientX, e.clientY); @@ -517,6 +518,39 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps MarqueeOptionsMenu.Instance.fadeOut(true); }); + + + @undoBatch + generateScrapbook = action(() => { + let docs = new Array<Doc>(); + const selected = this.marqueeSelect(false).map(d => { + this._props.removeDocument?.(d); + d.x = NumCast(d.x) - this.Bounds.left; + d.y = NumCast(d.y) - this.Bounds.top; + docs.push(d); + return d; + }); + const scrapbook = Docs.Create.ScrapbookDocument(docs, { + backgroundColor: '#e2ad32', + x: this.Bounds.left, + y: this.Bounds.top, + followLinkToggle: true, + _width: 200, + _height: 200, + _layout_showSidebar: true, + title: 'overview', + }); + const portal = Docs.Create.FreeformDocument(selected, { title: 'summarized documents', x: this.Bounds.left + 200, y: this.Bounds.top, isGroup: true, backgroundColor: 'transparent' }); + DocUtils.MakeLink(scrapbook, portal, { link_relationship: 'summary of:summarized by' }); + + portal.hidden = true; + this._props.addDocument?.(portal); + //this._props.addLiveTextDocument(summary); + this._props.addDocument?.(scrapbook); + MarqueeOptionsMenu.Instance.fadeOut(true); + }); + + @action marqueeCommand = (e: KeyboardEvent) => { const ee = e as unknown as KeyboardEvent & { propagationIsStopped?: boolean }; @@ -538,6 +572,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps if (e.key === 'g') this.collection(e, true); if (e.key === 'c' || e.key === 't') this.collection(e); if (e.key === 's' || e.key === 'S') this.summary(); + if (e.key === 'g' || e.key === 'G') this.generateScrapbook(); // ← scrapbook shortcut if (e.key === 'p') this.pileup(); this.cleanupInteractions(false); } diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index f7ad5c7e2..9d459d7eb 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -7,8 +7,9 @@ import { observer } from 'mobx-react'; import { extname } from 'path'; import * as React from 'react'; import { AiOutlineSend } from 'react-icons/ai'; +import { ImageLabelBoxData } from '../collections/collectionFreeForm/ImageLabelBox'; import ReactLoading from 'react-loading'; -import { ClientUtils, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../ClientUtils'; +import { ClientUtils, imageUrlToBase64, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; @@ -16,7 +17,7 @@ import { InkTool } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; import { ComputedField } from '../../../fields/ScriptField'; -import { Cast, DocCast, ImageCast, NumCast, RTFCast, StrCast } from '../../../fields/Types'; +import { Cast, DocCast, ImageCast, NumCast, RTFCast, StrCast, ImageCastWithSuffix } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; @@ -45,6 +46,8 @@ import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import './ImageBox.scss'; import { OpenWhere } from './OpenWhere'; +import { gptImageLabel } from '../../apis/gpt/GPT'; +import { ImageLabelBox } from '../collections/collectionFreeForm/ImageLabelBox'; const DefaultPath = '/assets/unknown-file-icon-hi.png'; export class ImageEditorData { @@ -117,6 +120,52 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this._dropDisposer?.(); ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document)); }; + + autoTag = async () => { + + try { + // 1) grab the full-size URL + const layoutKey = Doc.LayoutDataKey(this.Document); + const url = ImageCastWithSuffix(this.Document[layoutKey], '_o') ?? ''; + if (!url) throw new Error('No image URL found'); + + // 2) convert to base64 + const base64 = await imageUrlToBase64(url); + if (!base64) throw new Error('Failed to load image data'); + + // 3) ask GPT for exactly one label: PERSON or LANDSCAPE + const raw = await gptImageLabel( + base64, + 'Classify this image as PERSON or LANDSCAPE. You may only respond with one of these two options.' + ); + + // 4) normalize and prefix + const label = raw + .trim() + .toUpperCase() + + // 5) stash it on the Doc + // overwrite any old tags so re-runs still work + this.Document.$tags_chat = new List<string>(); + (this.Document.$tags_chat as List<string>).push(label); + + // 6) flip on “show tags” in the layout + // (same flag that ImageLabelBox.toggleDisplayInformation uses) + //note to self: What if i used my own field (ex: Document.$auto_description or something + //Would i still have to toggle it on for it to show in the metadata? + this.Document._layout_showTags = true; + + } catch (err) { + console.error('autoTag failed:', err); + } finally { + } + }; + + //Doc.getDescription(this.Document).then(desc => this.desc = desc) + + + + getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor const anchor = diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 57720baae..97049d0eb 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -64,6 +64,7 @@ import { removeMarkWithAttrs } from './prosemirrorPatches'; import { RichTextMenu, RichTextMenuPlugin } from './RichTextMenu'; import { RichTextRules } from './RichTextRules'; import { schema } from './schema_rts'; +import { tickStep } from 'd3'; // import * as applyDevTools from 'prosemirror-dev-tools'; export interface FormattedTextBoxProps extends FieldViewProps { @@ -304,6 +305,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB } }; + autoTag = async () => { + this.Document.$tags_chat = new List<string>(); + gptAPICall(RTFCast(this.Document[Doc.LayoutDataKey(this.Document)])?.Text ?? StrCast(this.Document[Doc.LayoutDataKey(this.Document)]), + GPTCallType.CLASSIFYTEXT).then(desc => (this.Document.$tags_chat as List<string>).push(desc)); + this.Document._layout_showTags = true; + //or... then(desc => this.Document.$tags_chat = desc); + } + leafText = (node: Node) => { if (node.type === this.EditorView?.state.schema.nodes.dashField) { const refDoc = !node.attrs.docId ? this.rootDoc : (DocServer.GetCachedRefField(node.attrs.docId as string) as Doc); @@ -1237,6 +1246,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB }, { fireImmediately: true } ); + this._disposers.search = reaction( () => Doc.IsSearchMatch(this.Document), @@ -1270,6 +1280,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB { fireImmediately: true } ); + this._disposers.tagger = reaction( + () => ({ title: this.Document.title, sel: this.props.isSelected() }), + action(() => { + this.autoTag(); + }), + { fireImmediately: true } + ); + if (!this._props.dontRegisterView) { this._disposers.record = reaction( () => this.recordingDictation, diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx index 6cfe9a62c..731715964 100644 --- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx @@ -1,6 +1,6 @@ import { action, makeObservable, observable } from 'mobx'; import * as React from 'react'; -import { Doc, DocListCast } from '../../../../fields/Doc'; +import { Doc, DocListCast, StrListCast } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; import { emptyFunction } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; @@ -12,21 +12,36 @@ import { FieldView, FieldViewProps } from '../FieldView'; import { DragManager } from '../../../util/DragManager'; import { RTFCast, StrCast, toList } from '../../../../fields/Types'; import { undoable } from '../../../util/UndoManager'; +import { ScrapbookItemConfig, ScrapbookPreset } from './ScrapbookPreset'; + +enum ScrapbookPresetType { + Classic = 'Classic', + Default = 'Default', + Collage = 'Collage', + Spotlight = 'Spotlight', +} + // Scrapbook view: a container that lays out its child items in a grid/template export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable createdDate: string; + @observable configs : ScrapbookItemConfig[] constructor(props: FieldViewProps) { super(props); makeObservable(this); this.createdDate = this.getFormattedDate(); + this.configs = + ScrapbookPreset.createPreset(presetType); + // ensure we always have a List<Doc> in dataDoc['items'] if (!this.dataDoc[this.fieldKey]) { this.dataDoc[this.fieldKey] = new List<Doc>(); } this.createdDate = this.getFormattedDate(); + //this.initScrapbook(ScrapbookPresetType.Default); this.setTitle(); + //this.setLayout(ScrapbookPreset.Spotlight); } public static LayoutString(fieldStr: string) { @@ -41,6 +56,188 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() }); } + + @action + initScrapbook(presetType: ScrapbookPresetType) { + // 1) ensure title is set + const title = `Scrapbook - ${this.createdDate}`; + if (this.dataDoc.title !== title) { + this.dataDoc.title = title; + } + + // 2) build placeholders from the preset + const configs = ScrapbookPreset.createPreset(presetType); + const placeholders: Doc[] = []; + + for (const cfg of configs) { + if (cfg.children) { + // --- nested container --- + const childDocs = cfg.children.map(child => { + const doc = Docs.Create.TextDocument(child.tag); + doc.accepts_docType = child.type; + doc.accepts_tagType = child.acceptTag ?? child.tag; + + const ph = new Doc(); + ph.proto = doc; + ph.original = doc; + ph.x = child.x; + ph.y = child.y; + if (child.width != null) ph._width = child.width; + if (child.height != null) ph._height = child.height; + return ph; + }); + + const protoW = cfg.containerWidth ?? cfg.width; + const protoH = cfg.containerHeight ?? cfg.height; + const containerProto = Docs.Create.StackingDocument( + childDocs, + { + ...(protoW != null ? { _width: protoW } : {}), + ...(protoH != null ? { _height: protoH } : {}), + title: cfg.tag + } + ); + + const ph = new Doc(); + ph.proto = containerProto; + ph.original = containerProto; + ph.x = cfg.x; + ph.y = cfg.y; + if (cfg.width != null) ph._width = cfg.width; + if (cfg.height != null) ph._height = cfg.height; + placeholders.push(ph); + + } else { + // --- flat placeholder --- + const doc = Docs.Create.TextDocument(cfg.tag); + doc.accepts_docType = cfg.type; + doc.accepts_tagType = cfg.acceptTag ?? cfg.tag; + + const ph = new Doc(); + ph.proto = doc; + ph.original = doc; + ph.x = cfg.x; + ph.y = cfg.y; + if (cfg.width != null) ph._width = cfg.width; + if (cfg.height != null) ph._height = cfg.height; + placeholders.push(ph); + } + } + + // 3) commit them into the field + this.dataDoc[this.fieldKey] = new List<Doc>(placeholders); + } + @action + //INACTIVE VER ignore!! not in use rn, implementation ver 1 + setLayout(preset: ScrapbookPreset) { + // helper to wrap a TextDocument proto in a Doc with positioning + function makePlaceholder( + proto: Doc, x: number, y: number, + width: number, height: number + ): Doc { + const d = new Doc(); + d.proto = proto; + d.original = proto; + d.x = x; + d.y = y; + d._width = width; + d._height = height; + return d; + } + + let placeholders: Doc[]; + + switch (preset) { + case ScrapbookPresetType.Classic: + // One large landscape image on top, caption below, sidebar at right + const imgClassic = Docs.Create.TextDocument('image'); + imgClassic.accepts_docType = DocumentType.IMG; + imgClassic.accepts_tagType = 'LANDSCAPE'; + const phImageClassic = makePlaceholder(imgClassic, 0, -120, 300, 180); + + const captionClassic = Docs.Create.TextDocument('caption'); + captionClassic.accepts_docType = DocumentType.RTF; + captionClassic.accepts_tagType = 'caption'; + const phCaptionClassic = makePlaceholder(captionClassic, 0, 80, 300, 60); + + const sidebarClassic = Docs.Create.TextDocument('sidebar'); + sidebarClassic.accepts_docType = DocumentType.RTF; + sidebarClassic.accepts_tagType = 'lengthy description'; + const phSidebarClassic = makePlaceholder(sidebarClassic, 320, -50, 80, 200); + + placeholders = [phImageClassic, phCaptionClassic, phSidebarClassic]; + break; + + case ScrapbookPresetType.Collage: + // Grid of four person images, small captions under each + const personDocs: Doc[] = []; + for (let i = 0; i < 4; i++) { + const img = Docs.Create.TextDocument(`person ${i+1}`); + img.accepts_docType = DocumentType.IMG; + img.accepts_tagType = 'PERSON'; + // position in 2x2 grid + const x = (i % 2) * 160 - 80; + const y = Math.floor(i / 2) * 160 - 80; + personDocs.push(makePlaceholder(img, x, y, 150, 120)); + + const cap = Docs.Create.TextDocument(`caption ${i+1}`); + cap.accepts_docType = DocumentType.RTF; + cap.accepts_tagType = 'caption'; + personDocs.push(makePlaceholder(cap, x, y + 70, 150, 30)); + } + placeholders = personDocs; + break; + + case ScrapbookPresetType.Spotlight: + // Full-width title, then a stacking of an internal person image + landscape, then description + const titleSpot = Docs.Create.TextDocument('title'); + titleSpot.accepts_docType = DocumentType.RTF; + titleSpot.accepts_tagType = 'title'; + const phTitleSpot = makePlaceholder(titleSpot, 0, -180, 400, 60); + + const internalImg = Docs.Create.TextDocument('<person>'); + internalImg.accepts_docType = DocumentType.IMG; + internalImg.accepts_tagType = 'PERSON'; + const phInternal = makePlaceholder(internalImg, -100, -120, 120, 160); + + const landscapeImg = Docs.Create.TextDocument('<landscape>'); + landscapeImg.accepts_docType = DocumentType.IMG; + landscapeImg.accepts_tagType = 'LANDSCAPE'; + const phLandscape = makePlaceholder(landscapeImg, 50, 0, 200, 160); + + const stack = Docs.Create.StackingDocument( + [phInternal, phLandscape], + { _width: 360, _height: 180, title: 'spotlight stack' } + ); + const phStack = (() => { + const d = new Doc(); + d.proto = stack; + d.original = stack; + d.x = 8; + d.y = -84; + d._width = 360; + d._height = 180; + return d; + })(); + + const descSpot = Docs.Create.TextDocument('description'); + descSpot.accepts_docType = DocumentType.RTF; + descSpot.accepts_tagType = 'lengthy description'; + const phDescSpot = makePlaceholder(descSpot, 0, 140, 400, 100); + + placeholders = [phTitleSpot, phStack, phDescSpot]; + break; + + default: + placeholders = []; + } + + // finally assign into the dataDoc + this.dataDoc[this.fieldKey] = new List<Doc>(placeholders); + } + + + @action setTitle() { const title = `Scrapbook - ${this.createdDate}`; @@ -49,6 +246,7 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() const image = Docs.Create.TextDocument('image'); image.accepts_docType = DocumentType.IMG; + image.accepts_tagType = 'LANDSCAPE' //should i be writing fields on this doc? clarify diff between this and proto, original const placeholder = new Doc(); placeholder.proto = image; placeholder.original = image; @@ -57,10 +255,11 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() placeholder.x = 0; placeholder.y = -100; //placeholder.overrideFields = new List<string>(['x', 'y']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos - + const summary = Docs.Create.TextDocument('summary'); summary.accepts_docType = DocumentType.RTF; - summary.accepts_textType = 'one line'; + summary.accepts_tagType = 'caption'; + //summary.$tags_chat = new List<string>(['lengthy description']); //we need to go back and set this const placeholder2 = new Doc(); placeholder2.proto = summary; placeholder2.original = summary; @@ -68,11 +267,64 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() placeholder2.y = 200; placeholder2._width = 250; //placeholder2.overrideFields = new List<string>(['x', 'y', '_width']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos - this.dataDoc[this.fieldKey] = new List<Doc>([placeholder, placeholder2]); + + const sidebar = Docs.Create.TextDocument('sidebar'); + sidebar.accepts_docType = DocumentType.RTF; + sidebar.accepts_tagType = 'lengthy description'; //accepts_textType = 'lengthy description' + const placeholder3 = new Doc(); + placeholder3.proto = sidebar; + placeholder3.original = sidebar; + placeholder3.x = 280; + placeholder3.y = -50; + placeholder3._width = 50; + placeholder3._height = 200; + + + + const internalImg = Docs.Create.TextDocument('image internal'); + internalImg.accepts_docType = DocumentType.IMG; + internalImg.accepts_tagType = 'PERSON' //should i be writing fields on this doc? clarify diff between this and proto, original + const placeholder5 = new Doc(); + placeholder5.proto = internalImg; + placeholder5.original = internalImg; + placeholder5._width = 50; + placeholder5._height = 100; + placeholder5.x = 0; + placeholder5.y = -100; + + const collection = Docs.Create.StackingDocument([placeholder5], { _width: 300, _height: 300, title: "internal coll" }); + //collection.accepts_docType = DocumentType.COL; don't mark this field + const placeholder4 = new Doc(); + placeholder4.proto = collection; + placeholder4.original = collection; + placeholder4.x = -200; + placeholder4.y = -100; + placeholder4._width = 100; + placeholder4._height = 200; + /*note-to-self + would doing: + + const collection = Docs.Create.ScrapbookDocument([placeholder, placeholder2, placeholder3]); + create issues with references to the same object?*/ + + /*note-to-self + Should we consider that there are more collections than just COL type collections? + when spreading*/ + + + + /*note-to-self + difference between passing a new List<Doc> versus just the raw array? + */ + + this.dataDoc[this.fieldKey] = this.dataDoc[this.fieldKey] ?? new List<Doc>([placeholder, placeholder2, placeholder3, placeholder4]); + + } } componentDidMount() { + //this.initScrapbook(ScrapbookPresetType.Default); this.setTitle(); } @@ -86,20 +338,64 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() }; filterAddDocument = (docIn: Doc | Doc[]) => { - const docs = toList(docIn); + const docs = toList(docIn); //The docs being added to the scrapbook + + // 1) Grab all template slots: + const slots = DocListCast(this.dataDoc[this.fieldKey]); + + // 2) recursive unwrap: + const unwrap = (items: Doc[]): Doc[] => + items.flatMap(d => + d.$type === DocumentType.COL + ? unwrap(DocListCast(d[Doc.LayoutDataKey(d)])) + : [d] + ); + + // 3) produce a flat list of every doc, unwrapping any number of nested COLs + const allDocs: Doc[] = unwrap(slots); + + if (docs?.length === 1) { - const placeholder = DocListCast(this.dataDoc[this.fieldKey]).find(d => + const placeholder = allDocs.filter(d => + (d.accepts_docType === docs[0].$type || // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type RTFCast(d[Doc.LayoutDataKey(d)])?.Text.includes(StrCast(docs[0].$type))) ); // prettier-ignore + //DocListCast(this.Document.items).map(doc => DocListCast(doc[Doc.LayoutDataKey(doc)]) + if (placeholder) { + /**Look at the autotags and see what matches*RTFCast(d[Doc.LayoutDataKey(d)])?.Text*/ // ugh. we have to tell the underlying view not to add the Doc so that we can add it where we want it. // However, returning 'false' triggers an undo. so this settimeout is needed to make the assignment happen after the undo. setTimeout( undoable(() => { + + const slotTagsList: Set<string>[] = placeholder.map(doc => + new Set<string>(StrListCast(doc.$tags_chat)) + ); + // turn docs[0].$tags_chat into a Set + const targetTags = new Set(StrListCast(docs[0].$tags_chat)); + //StrListCast(placeholder.overrideFields).map(field => (docs[0][field] = placeholder[field])); // // shouldn't need to do this for layout fields since the placeholder already overrides its protos - placeholder.proto = docs[0]; + + // find the first placeholder that shares *any* tag + const match = placeholder.find(ph => + ph.accepts_tagType != null && // make sure it actually has one + targetTags.has(StrCast(ph.accepts_tagType)) // test membership in the Set + //StrListCast(ph.$tags_chat).some(tag => targetTags.has(tag)) + ); + if (match) { + match.proto = docs[0]; + } + + /*const chosenPlaceholder = placeholder.find(d => + pl = new Set<string>(StrListCast(d.$tags_chat) + + d.$tags_chat && d.$tags_chat[0].equals(docs[0].$tags_chat)); //why [0] + if (chosenPlaceholder){ + chosenPlaceholder.proto = docs[0];}*/ + //excess if statement?? }, 'Scrapbook add') ); return false; @@ -124,6 +420,37 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() } } + + +function extractScrapbookConfigs(docs: Doc[]): ScrapbookItemConfig[] { + return docs.map(doc => extractConfig(doc)); +} + +// function extractConfig(doc: Doc): ScrapbookItemConfig { +// const layoutKey = Doc.LayoutDataKey(doc); +// const childDocs = doc[layoutKey] ? DocListCast(doc[layoutKey]) : []; + +// const isContainer = childDocs.length > 0; + +// const cfg: ScrapbookItemConfig = { +// type: isContainer ? DocumentType.COL : doc.$type, +// tag: +// acceptTag: doc.accepts_tagType, +// x: doc.x || 0, +// y: doc.y || 0, +// width: doc._width, +// height: doc._height, +// }; + +// if (isContainer) { +// cfg.containerWidth = doc.proto._width; +// cfg.containerHeight = doc.proto._height; +// cfg.children = childDocs.map(child => extractConfig(child)); +// } + +// return cfg; +// } + // Register scrapbook Docs.Prototypes.TemplateMap.set(DocumentType.SCRAPBOOK, { layout: { view: ScrapbookBox, dataField: 'items' }, diff --git a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx new file mode 100644 index 000000000..3cae4382b --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx @@ -0,0 +1,146 @@ +import { DocumentType } from '../../../documents/DocumentTypes'; + +export enum ScrapbookPresetType { + Default = 'Default', + Classic = 'Classic', + Collage = 'Collage', + Spotlight = 'Spotlight', +} + +export interface ScrapbookItemConfig { + type: DocumentType; + /** text shown in the placeholder bubble */ + tag: string; + /** what this slot actually accepts (defaults to `tag`) */ + acceptTag?: string; + + x: number; + y: number; + /** the frame this placeholder occupies */ + width?: number; + height?: number; + /** if this is a container with children, use these for the proto’s own size */ + containerWidth?: number; + containerHeight?: number; + children?: ScrapbookItemConfig[]; +} + +export class ScrapbookPreset { + static createPreset(presetType: ScrapbookPresetType): ScrapbookItemConfig[] { + switch (presetType) { + case ScrapbookPresetType.Classic: + return ScrapbookPreset.createClassicPreset(); + case ScrapbookPresetType.Collage: + return ScrapbookPreset.createCollagePreset(); + case ScrapbookPresetType.Spotlight: + return ScrapbookPreset.createSpotlightPreset(); + case ScrapbookPresetType.Default: + return ScrapbookPreset.createDefaultPreset(); + default: + throw new Error(`Unknown preset type: ${presetType}`); + } + } + + private static createClassicPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, + tag: 'LANDSCAPE', + acceptTag: 'LANDSCAPE', + x: 0, y: -100, width: 250, height: 200 + }, + { type: DocumentType.RTF, + tag: 'caption', + acceptTag: 'caption', + x: 0, y: 200, width: 250, height: 50 + }, + { type: DocumentType.RTF, + tag: 'lengthy description', + acceptTag: 'lengthy description', + x: 280, y: -50, width: 50, height: 200 + }, + { type: DocumentType.IMG, + tag: 'PERSON', + acceptTag: 'PERSON', + x: -200, y: -100, width: 100, height: 200 + }, + ]; + } + + private static createDefaultPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, + tag: 'image', + acceptTag: 'LANDSCAPE', + x: 0, y: -100, width: 250, height: 200 + }, + { type: DocumentType.RTF, + tag: 'summary', + acceptTag: 'caption', + x: 0, y: 200, width: 250 + }, + { type: DocumentType.RTF, + tag: 'sidebar', + acceptTag: 'lengthy description', + x: 280, y: -50, width: 50, height: 200 + }, + { + type: DocumentType.COL, + tag: 'internal coll', + x: -200, y: -100, width: 100, height: 200, + containerWidth: 300, containerHeight: 300, + children: [ + { type: DocumentType.IMG, + tag: 'image internal', + acceptTag: 'PERSON', + x: 0, y: 0, width: 50, height: 100 + } + ] + } + ]; + } + + private static createCollagePreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, + tag: 'LANDSCAPE', + acceptTag: 'LANDSCAPE', + x: -150, y: -150, width: 150, height: 150 + }, + { type: DocumentType.IMG, + tag: 'PERSON', + acceptTag: 'PERSON', + x: 0, y: -150, width: 150, height: 150 + }, + { type: DocumentType.RTF, + tag: 'caption', + acceptTag: 'caption', + x: -150, y: 0, width: 300, height: 100 + }, + { type: DocumentType.RTF, + tag: 'lengthy description', + acceptTag: 'lengthy description', + x: 0, y: 100, width: 300, height: 100 + } + ]; + } + + private static createSpotlightPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.RTF, + tag: 'title', + acceptTag: 'title', + x: 0, y: -180, width: 300, height: 40 + }, + { type: DocumentType.IMG, + tag: 'LANDSCAPE', + acceptTag: 'LANDSCAPE', + x: 0, y: 0, width: 300, height: 200 + }, + { type: DocumentType.RTF, + tag: 'caption', + acceptTag: 'caption', + x: 0, y: 230, width: 300, height: 50 + } + ]; + } +} diff --git a/src/client/views/nodes/scrapbook/ScrapbookSettingsPanel.tsx b/src/client/views/nodes/scrapbook/ScrapbookSettingsPanel.tsx new file mode 100644 index 000000000..5808ab4d1 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookSettingsPanel.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faRedoAlt } from '@fortawesome/free-solid-svg-icons'; +import { action } from 'mobx'; + +export default class ScrapbookSettingsPanel extends React.Component { + + constructor(props) { + super(props); + this.state = { regenerating: false }; + } + + regenerateScrapbook = async () => { + this.setState({ regenerating: true }); + try { + // Example API call or method invoking ChatGPT for JSON + const newLayout = await fetch('/api/generate-scrapbook-layout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentLayout: this.props.currentLayout }) + }).then(res => res.json()); + + action(() => { + // Apply new layout + this.props.applyNewLayout(newLayout); + })(); + } catch (err) { + console.error('Failed to regenerate layout:', err); + } finally { + this.setState({ regenerating: false }); + } + }; + + render() { + const { regenerating } = this.state; + + return ( + <div className="scrapbook-settings-panel" style={{ display: 'flex', alignItems: 'center', padding: '8px', backgroundColor: '#f0f0f0', borderRadius: '8px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}> + <button + className="regenerate-scrapbook-btn" + title="Regenerate Scrapbook" + onClick={this.regenerateScrapbook} + disabled={regenerating} + style={{ + padding: '8px 12px', + background: regenerating ? '#ccc' : '#007bff', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: regenerating ? 'default' : 'pointer', + display: 'flex', + alignItems: 'center' + }}> + <FontAwesomeIcon icon={faRedoAlt} style={{ marginRight: '6px' }} /> + {regenerating ? 'Regenerating...' : 'Regenerate Scrapbook'} + </button> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx index 3ad5bc844..256e68afd 100644 --- a/src/client/views/search/FaceRecognitionHandler.tsx +++ b/src/client/views/search/FaceRecognitionHandler.tsx @@ -9,6 +9,8 @@ import { ImageField } from '../../../fields/URLField'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; +import { reaction } from 'mobx'; +import { DocumentView } from '../nodes/DocumentView'; /** * A singleton class that handles face recognition and manages face Doc collections for each face found. @@ -33,7 +35,7 @@ export class FaceRecognitionHandler { // eslint-disable-next-line no-use-before-define static _instance: FaceRecognitionHandler; private _apiModelReady = false; - private _pendingAPIModelReadyDocs: Doc[] = []; + private _pendingAPIModelReadyDocs: DocumentView[] = []; public static get Instance() { return FaceRecognitionHandler._instance ?? new FaceRecognitionHandler(); @@ -126,7 +128,7 @@ export class FaceRecognitionHandler { constructor() { FaceRecognitionHandler._instance = this; this.loadAPIModels().then(() => this._pendingAPIModelReadyDocs.forEach(this.classifyFacesInImage)); - DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.classifyFacesInImage(dv.Document)); + DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.classifyFacesInImage(dv)); } /** @@ -199,14 +201,18 @@ export class FaceRecognitionHandler { * match them to existing unique faces, otherwise new unique face(s) are created. * @param imgDoc The document being analyzed. */ - private classifyFacesInImage = async (imgDoc: Doc) => { + private classifyFacesInImage = async (imgDocView: DocumentView) => { + const imgDoc = imgDocView.Document; if (!Doc.UserDoc().recognizeFaceImages) return; const activeDashboard = Doc.ActiveDashboard; if (!this._apiModelReady || !activeDashboard) { - this._pendingAPIModelReadyDocs.push(imgDoc); + this._pendingAPIModelReadyDocs.push(imgDocView); } else if (imgDoc.type === DocumentType.LOADING && !imgDoc.loadingError) { - setTimeout(() => this.classifyFacesInImage(imgDoc), 1000); + setTimeout(() => this.classifyFacesInImage(imgDocView), 1000); } else { + reaction(() => ({sel:imgDocView.isSelected()}), ({sel}) => !sel && + imgDocView.ComponentView?.autoTag?.(), {fireImmediately: true} + ) const imgUrl = ImageCast(imgDoc[Doc.LayoutDataKey(imgDoc)]); if (imgUrl && !DocListCast(Doc.MyFaceCollection?.examinedFaceDocs).includes(imgDoc[DocData])) { // only examine Docs that have an image and that haven't already been examined. |