From 6900008b1bb89cca1eab7b95f17ee33fa335282f Mon Sep 17 00:00:00 2001
From: sharkiecodes <lanyi_stroud@brown.edu>
Date: Mon, 28 Apr 2025 20:18:26 -0400
Subject: adding autotagging

---
 src/client/views/search/FaceRecognitionHandler.tsx | 16 +++++++++++-----
 1 file changed, 11 insertions(+), 5 deletions(-)

(limited to 'src/client/views/search/FaceRecognitionHandler.tsx')

diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index cb837e3ab..436cca8cf 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 && imgDoc.type == 'text'
+            && 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.
-- 
cgit v1.2.3-70-g09d2


From 609ec6d37b5abf94f3ab84784544ebe47804cdf5 Mon Sep 17 00:00:00 2001
From: sharkiecodes <lanyi_stroud@brown.edu>
Date: Tue, 29 Apr 2025 23:01:11 -0400
Subject: Autotag

---
 src/client/apis/gpt/GPT.ts                         | 35 +++++++++++----
 .../collectionFreeForm/ImageLabelBox.tsx           |  2 +-
 src/client/views/nodes/ImageBox.tsx                | 48 ++++++++++++++++++---
 .../views/nodes/formattedText/FormattedTextBox.tsx | 18 ++++++++
 src/client/views/nodes/scrapbook/ScrapbookBox.tsx  | 50 ++++++++++++++++++++--
 src/client/views/search/FaceRecognitionHandler.tsx |  4 +-
 6 files changed, 137 insertions(+), 20 deletions(-)

(limited to 'src/client/views/search/FaceRecognitionHandler.tsx')

diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index 9cb47995c..1956fef0c 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: {
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index bf674c025..038b1c6f9 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -160,7 +160,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
 
     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.
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index 6e6ca3f73..0b6814c01 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';
@@ -46,6 +47,7 @@ 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';
@@ -122,11 +124,47 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
     };
 
     autoTag = async () => {
-       ImageLabelBox.Instance.classifyImagesInBox([this.Document], "Classify this image as a PERSON or LANDSCAPE. You may only respond with one of these two options");
-        
 
-        //Doc.getDescription(this.Document).then(desc => this.desc = desc)
+    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)
+    
 
 
 
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 98e461a52..f4cbbcc9e 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);
@@ -1236,6 +1245,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
             },
             { fireImmediately: true }
         );
+        
 
         this._disposers.search = reaction(
             () => Doc.IsSearchMatch(this.Document),
@@ -1269,6 +1279,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 55d0df585..39729a1c5 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';
@@ -100,20 +100,62 @@ 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 =>
+                            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;
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 60744588f..256e68afd 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -210,8 +210,8 @@ export class FaceRecognitionHandler {
         } else if (imgDoc.type === DocumentType.LOADING && !imgDoc.loadingError) {
             setTimeout(() => this.classifyFacesInImage(imgDocView), 1000);
         } else {
-            reaction(() => ({sel:imgDocView.isSelected()}), ({sel}) => !sel && imgDoc.type == 'text'
-            && imgDocView.ComponentView?.autoTag?.(), {fireImmediately: true} 
+            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])) {
-- 
cgit v1.2.3-70-g09d2


From bf33580a66c1f8ce87e85bea701415788a887401 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 11 Jun 2025 11:50:45 -0400
Subject: change how autoTag is triggered for images to not use a reaction

---
 src/client/views/nodes/ImageBox.tsx                | 83 +++++++++-------------
 src/client/views/search/FaceRecognitionHandler.tsx |  5 +-
 2 files changed, 34 insertions(+), 54 deletions(-)

(limited to 'src/client/views/search/FaceRecognitionHandler.tsx')

diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index 1e16bbfc9..d7e21b0a6 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -142,56 +142,39 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
     };
 
     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 labels 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. Then
-        provide five additional descriptive tags to describe the image for a total of 6 words outputted, 
-        delimited by spaces. For example: "LANDSCAPE BUNNY NATURE FOREST PEACEFUL OUTDOORS". Then add one final lengthier summary tag (separated by underscores)
-        that describes the image.`
-      );
-
-    const { nativeWidth, nativeHeight } = this.nativeSize;
-    const aspectRatio = nativeWidth && nativeHeight
-      ? (nativeWidth / nativeHeight).toFixed(2)
-      : '1.00';
-
-      // 4) normalize and prefix
-      const label = raw
-        .trim()
-        .toUpperCase()
-
-      // 5) stash it on the Doc
-      //    overwrite any old tags so re-runs still work
-      const tokens = label.split(/\s+/);
-      this.Document.$tags_chat = new List<string>();
-      tokens.forEach(tok => {
-        (this.Document.$tags_chat as List<string>).push(tok)});
-        (this.Document.$tags_chat as List<string>).push(`ASPECT_${aspectRatio}`); 
-
-      // 6) flip on “show tags” in the layout 
-      //    (same flag that ImageLabelBox.toggleDisplayInformation uses)
-      this.Document._layout_showTags = true;
-
-    } catch (err) {
-      console.error('autoTag failed:', err);
-    } finally {
-    }
-  };
-      
-
-
+        if (this.Document.$tags_chat) return;
+        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 labels one label: PERSON or LANDSCAPE
+            const label = await gptImageLabel(
+                base64,
+                `Classify this image as PERSON or LANDSCAPE.  You may only respond with one of these two options. 
+                Then provide five additional descriptive tags to describe the image for a total of 6 words outputted, delimited by spaces. 
+                For example: "LANDSCAPE BUNNY NATURE FOREST PEACEFUL OUTDOORS". 
+                Then add one final lengthier summary tag (separated by underscores) that describes the image.`
+            ).then(raw => raw.trim().toUpperCase());
+
+            const { nativeWidth, nativeHeight } = this.nativeSize;
+            const aspectRatio = ((nativeWidth || 1) / (nativeHeight || 1)).toFixed(2);
+
+            // 5) stash it on the Doc
+            //    overwrite any old tags so re-runs still work
+            this.Document.$tags_chat = new List<string>([...label.split(/\s+/), `ASPECT_${aspectRatio}`]);
+
+            // 6) flip on “show tags” in the layout
+            this.Document._layout_showTags = true;
+        } catch (err) {
+            console.error('autoTag failed:', err);
+        }
+    };
 
     getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => {
         const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 256e68afd..dac91b89a 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -9,7 +9,6 @@ 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';
 
 /**
@@ -210,9 +209,7 @@ export class FaceRecognitionHandler {
         } else if (imgDoc.type === DocumentType.LOADING && !imgDoc.loadingError) {
             setTimeout(() => this.classifyFacesInImage(imgDocView), 1000);
         } else {
-            reaction(() => ({sel:imgDocView.isSelected()}), ({sel}) => !sel &&
-             imgDocView.ComponentView?.autoTag?.(), {fireImmediately: true} 
-        )
+            imgDocView.ComponentView?.autoTag?.();
             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.
-- 
cgit v1.2.3-70-g09d2


From 6c011f502118e5246aabd29a30494c669d917fb1 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 11 Jun 2025 12:15:39 -0400
Subject: don't autoTag text docs until they're dropped onto a scrapbook. 
 don't re-tag a text box that has not changed.

---
 .../collections/collectionFreeForm/MarqueeView.tsx |  8 ++------
 .../views/nodes/formattedText/FormattedTextBox.tsx | 23 +++++++++++-----------
 src/client/views/nodes/scrapbook/ScrapbookBox.tsx  |  5 ++++-
 src/client/views/search/FaceRecognitionHandler.tsx |  2 +-
 4 files changed, 19 insertions(+), 19 deletions(-)

(limited to 'src/client/views/search/FaceRecognitionHandler.tsx')

diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index 12515a72c..7a456c46f 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -16,7 +16,7 @@ import { DocumentType } from '../../../documents/DocumentTypes';
 import { Docs, DocumentOptions } from '../../../documents/Documents';
 import { SnappingManager, freeformScrollMode } from '../../../util/SnappingManager';
 import { Transform } from '../../../util/Transform';
-import { UndoManager, undoBatch, undoable } from '../../../util/UndoManager';
+import { UndoManager, undoBatch } from '../../../util/UndoManager';
 import { ContextMenu } from '../../ContextMenu';
 import { ObservableReactComponent } from '../../ObservableReactComponent';
 import { MarqueeViewBounds } from '../../PinFuncs';
@@ -33,8 +33,6 @@ import { requestAiGeneratedPreset, DocumentDescriptor } from '../../nodes/scrapb
 import { buildPlaceholdersFromConfigs, slotRealDocIntoPlaceholders } from '../../nodes/scrapbook/ScrapbookBox';
 import './MarqueeView.scss';
 
-import { build } from 'xregexp';
-
 interface MarqueeViewProps {
     Doc: Doc;
     getContainerTransform: () => Transform;
@@ -589,9 +587,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
         const allPlaceholders = unwrap(flatPl);
 
         // 4) Slot each selectedDocs[i] into the first matching placeholder
-        selectedDocs.forEach(realDoc => {
-            slotRealDocIntoPlaceholders(realDoc, allPlaceholders);
-        });
+        selectedDocs.forEach(realDoc => slotRealDocIntoPlaceholders(realDoc, allPlaceholders));
 
         const selected = this.marqueeSelect(false).map(d => {
             this._props.removeDocument?.(d);
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 1768eb08d..d700ce9f8 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -310,15 +310,17 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
 
     autoTag = () => {
         const rawText = RTFCast(this.Document[this.fieldKey])?.Text ?? StrCast(this.Document[this.fieldKey]);
-        const callType = rawText.includes('[placeholder]') ? GPTCallType.CLASSIFYTEXTMINIMAL : GPTCallType.CLASSIFYTEXTFULL;
-
-        gptAPICall(rawText, callType).then(
-            action(desc => {
-                // Split GPT response into tokens and push individually & clear existing tags
-                this.Document.$tags_chat = new List<string>(desc.trim().split(/\s+/));
-                this.Document._layout_showTags = true;
-            })
-        );
+        if (rawText && !this.Document.$tags_chat) {
+            const callType = rawText.includes('[placeholder]') ? GPTCallType.CLASSIFYTEXTMINIMAL : GPTCallType.CLASSIFYTEXTFULL;
+
+            gptAPICall(rawText, callType).then(
+                action(desc => {
+                    // Split GPT response into tokens and push individually & clear existing tags
+                    this.Document.$tags_chat = new List<string>(desc.trim().split(/\s+/));
+                    this.Document._layout_showTags = true;
+                })
+            );
+        }
     };
 
     leafText = (node: Node) => {
@@ -382,6 +384,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                             dataDoc[this.fieldKey] =
                                 numstring !== undefined ? Number(newText) : newText || (DocCast(dataDoc.proto)?.[this.fieldKey] === undefined && this.layoutDoc[this.fieldKey] === undefined) ? new RichTextField(newJson, newText) : undefined;
                             textChange && ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.Document, text: newText });
+                            if (textChange) this.dataDoc.$tags_chat = undefined;
                             this.ApplyingChange = ''; // turning this off here allows a Doc to retrieve data from template if noTemplate below is changed to false
                             unchanged = false;
                         }
@@ -1284,8 +1287,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
             { fireImmediately: true }
         );
 
-        this._disposers.tagger = reaction(() => ({ title: this.Document.title, sel: this._props.isSelected() }), 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 fcb82a6ba..94522c979 100644
--- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx
+++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx
@@ -57,7 +57,10 @@ export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]) {
         return createMessagePlaceholder(cfg);
     });
 }
-export function slotRealDocIntoPlaceholders(realDoc: Doc, placeholders: Doc[]): boolean {
+export async function slotRealDocIntoPlaceholders(realDoc: Doc, placeholders: Doc[]) {
+    if (!realDoc.$tags_chart) {
+        await DocumentView.getFirstDocumentView(realDoc)?.ComponentView?.autoTag?.();
+    }
     const realTags = new Set<string>(StrListCast(realDoc.$tags_chat).map(t => t.toLowerCase?.() ?? ''));
 
     // Find placeholder with most matching tags
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index dac91b89a..84404d65a 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -209,9 +209,9 @@ export class FaceRecognitionHandler {
         } else if (imgDoc.type === DocumentType.LOADING && !imgDoc.loadingError) {
             setTimeout(() => this.classifyFacesInImage(imgDocView), 1000);
         } else {
-            imgDocView.ComponentView?.autoTag?.();
             const imgUrl = ImageCast(imgDoc[Doc.LayoutDataKey(imgDoc)]);
             if (imgUrl && !DocListCast(Doc.MyFaceCollection?.examinedFaceDocs).includes(imgDoc[DocData])) {
+                imgDocView.ComponentView?.autoTag?.();
                 // only examine Docs that have an image and that haven't already been examined.
                 Doc.MyFaceCollection && Doc.AddDocToList(Doc.MyFaceCollection, 'examinedFaceDocs', imgDoc[DocData]);
                 FaceRecognitionHandler.loadImage(imgUrl).then(
-- 
cgit v1.2.3-70-g09d2

