Jump to content

Welcome to the Family Commonplace Wiki! 👋 — Create an account to start contributing, or see Help:Getting Started if you're new here.

MediaWiki:Gadget-aiSummary.js: Difference between revisions

From Family Commonplace Wiki
Fix timing: check if VE already active on gadget load
Install AI edit summary gadget
 
Line 1: Line 1:
/**
/**
  * Gadget: AI Edit Summary (auto-fill)
  * Gadget: AI Edit Summary (auto-fill)
  * Fills the summary field automatically when the save dialog opens.
  *
* Automatically fills the edit summary field with a Claude-generated
* description the moment the save dialog opens. Works in VisualEditor
* and the classic wikitext editor.
  */
  */
( function () {
( function () {
     'use strict';
     'use strict';


     var CLAUDE_API_KEY = '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';
    // ── Configuration ──────────────────────────────────────────────────────
     var CLAUDE_MODEL  = 'claude-haiku-4-5-20251001';
     var CLAUDE_API_KEY = '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';         // ← paste sk-ant-… key
     var CLAUDE_MODEL  = 'claude-haiku-4-5-20251001'; // fast & cheap


    // ── Visual Editor ──────────────────────────────────────────────────────
     function setupVEObserver() {
     function setupVEObserver() {
         // Immediately check if save dialog is already open
         // Check immediately in case save dialog already open
         var $ta = $( '.ve-ui-mwSaveDialog-summary textarea' );
         var $ta = $( '.ve-ui-mwSaveDialog-summary textarea' );
         if ( $ta.length && !$ta.data( 'ai-triggered' ) ) {
         if ( $ta.length && !$ta.data( 'ai-done' ) ) {
             $ta.data( 'ai-triggered', true );
             $ta.data( 'ai-done', true );
             autoFill( $ta, 've' );
             autoFill( $ta, 've' );
             addRedoLink( $ta, 've' );
             addRedoLink( $ta, 've' );
         }
         }
        // Watch for it to open in the future
 
         var observer = new MutationObserver( function () {
         var observer = new MutationObserver( function () {
             var $ta2 = $( '.ve-ui-mwSaveDialog-summary textarea' );
             var $t = $( '.ve-ui-mwSaveDialog-summary textarea' );
             if ( $ta2.length && !$ta2.data( 'ai-triggered' ) ) {
             if ( $t.length && !$t.data( 'ai-done' ) ) {
                 $ta2.data( 'ai-triggered', true );
                 $t.data( 'ai-done', true );
                 autoFill( $ta2, 've' );
                 autoFill( $t, 've' );
                 addRedoLink( $ta2, 've' );
                 addRedoLink( $t, 've' );
             }
             }
         } );
         } );
Line 29: Line 34:
     }
     }


     // If VE is already active, set up immediately
     // Timing fix: VE may already be active when gadget first loads
     if ( window.ve && ve.init && ve.init.target ) {
     if ( window.ve && ve.init && ve.init.target ) {
         setupVEObserver();
         setupVEObserver();
     }
     }
    // Also hook for future VE activations
     mw.hook( 've.activationComplete' ).add( setupVEObserver );
     mw.hook( 've.activationComplete' ).add( setupVEObserver );


     // Classic wikitext editor
     // ── Classic wikitext editor ────────────────────────────────────────────
     $( function () {
     $( function () {
         var $field = $( '#wpSummary' );
         var $field = $( '#wpSummary' );
         if ( !$field.length ) { return; }
         if ( !$field.length ) { return; }
         addRedoLink( $field, 'wikitext' );
         addRedoLink( $field, 'wikitext' );
         $field.one( 'focus', function () { autoFill( $field, 'wikitext' ); } );
 
         $field.one( 'focus', function () {
            autoFill( $field, 'wikitext' );
        } );
     } );
     } );
    // ── Auto-fill ──────────────────────────────────────────────────────────


     function autoFill( $field, editorType ) {
     function autoFill( $field, editorType ) {
        // Don't overwrite something the user already typed
         if ( $field.val().trim() ) { return; }
         if ( $field.val().trim() ) { return; }
         var orig = $field.attr( 'placeholder' ) || '';
 
         $field.attr( 'placeholder', '\u2728 Writing AI summary\u2026' );
         var origPlaceholder = $field.attr( 'placeholder' ) || '';
         generateSummary( editorType )
         $field.attr( 'placeholder', 'Writing AI summary…' );
             .then( function ( s ) {
 
                 if ( !$field.val().trim() ) { $field.val( s ).trigger( 'input change' ); }
         buildPrompt( editorType )
            .then( callClaude )
             .then( function ( summary ) {
                // Only fill if still empty
                 if ( !$field.val().trim() ) {
                    $field.val( summary ).trigger( 'input change' );
                }
            } )
            .catch( function ( err ) {
                mw.log.warn( 'AI summary error:', err );
             } )
             } )
             .catch( function ( e ) { mw.log.warn( 'AI summary:', e ); } )
             .always( function () {
            .always( function () { $field.attr( 'placeholder', orig ); } );
                $field.attr( 'placeholder', origPlaceholder );
            } );
     }
     }


    // Small "↺ redo" link the user can click for a fresh suggestion
     function addRedoLink( $field, editorType ) {
     function addRedoLink( $field, editorType ) {
         $( '<a>' ).attr( 'href', '#' ).text( '\u21ba redo AI summary' )
         // Guard against duplicate links
             .css( { display: 'inline-block', marginTop: '4px', fontSize: '0.8em', color: '#36c' } )
        if ( $field.next( '.ai-redo' ).length ) { return; }
 
        var $redo = $( '<a>' )
            .addClass( 'ai-redo' )
            .attr( 'href', '#' )
            .attr( 'title', 'Generate a new AI summary' )
            .text( 'redo AI summary' )
             .css( {
                display:   'inline-block',
                marginTop: '4px',
                fontSize:   '0.8em',
                color:     '#36c',
                cursor:    'pointer'
            } )
             .on( 'click', function ( e ) {
             .on( 'click', function ( e ) {
                 e.preventDefault(); $field.val( '' ); autoFill( $field, editorType );
                 e.preventDefault();
             } ).insertAfter( $field );
                $field.val( '' );
                autoFill( $field, editorType );
             } );
 
        $field.after( $redo );
     }
     }


     function generateSummary( editorType ) {
     // ── Step 1: get editor content ─────────────────────────────────────────
        return getNewWikitext( editorType ).then( buildPrompt ).then( callClaude );
    }


     function getNewWikitext( editorType ) {
     function getEditorText( editorType ) {
         if ( editorType === 'wikitext' ) {
         if ( editorType === 'wikitext' ) {
             return $.Deferred().resolve( $( '#wpTextbox1' ).val() ).promise();
             return $( '#wpTextbox1' ).val() || '';
         }
         }
         if ( !window.ve || !ve.init || !ve.init.target ) {
        // Visual Editor: get plain text from document model
             return $.Deferred().reject( 'VE not ready' ).promise();
         if ( window.ve && ve.init && ve.init.target ) {
             var surface = ve.init.target.getSurface();
            if ( surface ) {
                try {
                    return surface.getModel().getDocument().data.getText( true );
                } catch ( e ) {
                    mw.log.warn( 'VE getText failed:', e );
                }
            }
         }
         }
         var surface = ve.init.target.getSurface();
         return '';
        if ( !surface ) { return $.Deferred().reject( 'no surface' ).promise(); }
        try {
            var html = new XMLSerializer().serializeToString(
                ve.dm.converter.getDomFromModel( surface.getModel().getDocument() ) );
            return new mw.Api().post( {
                action: 'visualeditor', paction: 'serialize',
                page: mw.config.get( 'wgPageName' ), html: html, format: 'json'
            } ).then( function ( d ) { return ( d.visualeditor && d.visualeditor.content ) || ''; } );
        } catch ( e ) { return $.Deferred().reject( String( e ) ).promise(); }
     }
     }


     function buildPrompt( newText ) {
    // ── Step 2: build prompt using saved wikitext + current editor text ────
         var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
 
         var revId = mw.config.get( 'wgRevisionId' );
     function buildPrompt( editorType ) {
         var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
         var revId = mw.config.get( 'wgRevisionId' );
        var newTxt = getEditorText( editorType );
 
        // New page — no previous revision to compare
         if ( !revId ) {
         if ( !revId ) {
             return $.Deferred().resolve( 'Family wiki. New page: "' + title + '".\n\n' + newText.slice( 0, 1200 ) ).promise();
             return $.Deferred().resolve(
                'Family wiki. New page: "' + title + '".\n\n' +
                'Content:\n' + newTxt.slice( 0, 1500 )
            ).promise();
         }
         }
        // Existing page: fetch saved wikitext and compare with current editor text
         return new mw.Api().get( {
         return new mw.Api().get( {
             action: 'compare', fromrev: revId, totext: newText, topst: 1, prop: 'diff', format: 'json'
             action: 'query',
            titles: mw.config.get( 'wgPageName' ),
            prop:   'revisions',
            rvprop: 'content',
            rvlimit: 1,
            format: 'json'
         } ).then( function ( d ) {
         } ).then( function ( d ) {
             var diff = parseDiff( ( d.compare && d.compare.body ) || '' );
             var pages  = d.query.pages;
             return 'Family wiki. Page: "' + title + '".\n\nDiff:\n' + diff;
            var page    = pages[ Object.keys( pages )[ 0 ] ];
        } ).catch( function () { return 'Family wiki. Page "' + title + '" was edited.'; } );
             var oldText = ( page.revisions && page.revisions[ 0 ][ '*' ] ) || '';
    }


    function parseDiff( html ) {
            return 'Family wiki. Page: "' + title + '".\n\n' +
        var $d = $( '<div>' ).html( html ), lines = [];
                  'SAVED WIKITEXT (before edit):\n' + oldText.slice( 0, 1400 ) + '\n\n' +
        $d.find( 'tr' ).each( function () {
                  'CURRENT EDITOR TEXT (after edit):\n' + newTxt.slice( 0, 1400 );
            var del = $( this ).find( 'td.diff-deletedline' ).text().trim();
        } ).catch( function () {
            var add = $( this ).find( 'td.diff-addedline' ).text().trim();
             return 'Family wiki. Page "' + title + '" edited.\n\nEditor text:\n' + newTxt.slice( 0, 1500 );
             if ( del ) { lines.push( '- ' + del ); }
            if ( add ) { lines.push( '+ ' + add ); }
         } );
         } );
        return lines.join( '\n' ).slice( 0, 2500 ) || '(no changes)';
     }
     }
    // ── Step 3: call Claude ────────────────────────────────────────────────


     function callClaude( prompt ) {
     function callClaude( prompt ) {
         return $.ajax( {
         return $.ajax( {
             url: 'https://api.anthropic.com/v1/messages', method: 'POST',
             url:         'https://api.anthropic.com/v1/messages',
            method:     'POST',
             contentType: 'application/json',
             contentType: 'application/json',
             headers: {
             headers: {
                 'x-api-key': CLAUDE_API_KEY,
                 'x-api-key':                                 CLAUDE_API_KEY,
                 'anthropic-version': '2023-06-01',
                 'anthropic-version':                         '2023-06-01',
                 'anthropic-dangerous-direct-browser-access': 'true'
                 'anthropic-dangerous-direct-browser-access': 'true'
             },
             },
             data: JSON.stringify( {
             data: JSON.stringify( {
                 model: CLAUDE_MODEL, max_tokens: 100,
                 model:     CLAUDE_MODEL,
                 system: 'You write concise MediaWiki edit summaries for a family history wiki. Reply with ONLY the summary — no quotes, no explanation. Max 70 chars. Active voice. Examples: "Add birth place for John Gianutsos", "Fix spouse link", "Update death date".',
                max_tokens: 100,
                 system: [
                    'You write concise MediaWiki edit summaries for a family history wiki.',
                    'Reply with ONLY the summary — no quotes, no explanation, no trailing punctuation.',
                    'Maximum 70 characters. Use active voice.',
                    'Examples: "Add birth place for John Gianutsos", "Fix spouse link", "Update death date to 1965"'
                ].join( ' ' ),
                 messages: [ { role: 'user', content: prompt } ]
                 messages: [ { role: 'user', content: prompt } ]
             } )
             } )
         } ).then( function ( r ) {
         } ).then( function ( resp ) {
             return r.content[ 0 ].text.trim().replace( /^["']|["']$/g, '' ).slice( 0, 70 );
             return resp.content[ 0 ].text
                      .trim()
                      .replace( /^["']|["']$/g, '' )
                      .slice( 0, 70 );
         }, function ( xhr ) {
         }, function ( xhr ) {
             var m = xhr.responseJSON && xhr.responseJSON.error && xhr.responseJSON.error.message;
             var msg = xhr.responseJSON &&
             return $.Deferred().reject( m || xhr.statusText ).promise();
                      xhr.responseJSON.error &&
                      xhr.responseJSON.error.message;
             return $.Deferred().reject( msg || xhr.statusText ).promise();
         } );
         } );
     }
     }


}() );
}() );

Latest revision as of 20:41, 23 May 2026

/**
 * Gadget: AI Edit Summary (auto-fill)
 *
 * Automatically fills the edit summary field with a Claude-generated
 * description the moment the save dialog opens. Works in VisualEditor
 * and the classic wikitext editor.
 */
( function () {
    'use strict';

    // ── Configuration ──────────────────────────────────────────────────────
    var CLAUDE_API_KEY = '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';         // ← paste sk-ant-… key
    var CLAUDE_MODEL   = 'claude-haiku-4-5-20251001'; // fast & cheap

    // ── Visual Editor ──────────────────────────────────────────────────────
    function setupVEObserver() {
        // Check immediately in case save dialog already open
        var $ta = $( '.ve-ui-mwSaveDialog-summary textarea' );
        if ( $ta.length && !$ta.data( 'ai-done' ) ) {
            $ta.data( 'ai-done', true );
            autoFill( $ta, 've' );
            addRedoLink( $ta, 've' );
        }

        var observer = new MutationObserver( function () {
            var $t = $( '.ve-ui-mwSaveDialog-summary textarea' );
            if ( $t.length && !$t.data( 'ai-done' ) ) {
                $t.data( 'ai-done', true );
                autoFill( $t, 've' );
                addRedoLink( $t, 've' );
            }
        } );
        observer.observe( document.body, { childList: true, subtree: true } );
    }

    // Timing fix: VE may already be active when gadget first loads
    if ( window.ve && ve.init && ve.init.target ) {
        setupVEObserver();
    }
    mw.hook( 've.activationComplete' ).add( setupVEObserver );

    // ── Classic wikitext editor ────────────────────────────────────────────
    $( function () {
        var $field = $( '#wpSummary' );
        if ( !$field.length ) { return; }

        addRedoLink( $field, 'wikitext' );

        $field.one( 'focus', function () {
            autoFill( $field, 'wikitext' );
        } );
    } );

    // ── Auto-fill ──────────────────────────────────────────────────────────

    function autoFill( $field, editorType ) {
        // Don't overwrite something the user already typed
        if ( $field.val().trim() ) { return; }

        var origPlaceholder = $field.attr( 'placeholder' ) || '';
        $field.attr( 'placeholder', '✨ Writing AI summary…' );

        buildPrompt( editorType )
            .then( callClaude )
            .then( function ( summary ) {
                // Only fill if still empty
                if ( !$field.val().trim() ) {
                    $field.val( summary ).trigger( 'input change' );
                }
            } )
            .catch( function ( err ) {
                mw.log.warn( 'AI summary error:', err );
            } )
            .always( function () {
                $field.attr( 'placeholder', origPlaceholder );
            } );
    }

    // Small "↺ redo" link the user can click for a fresh suggestion
    function addRedoLink( $field, editorType ) {
        // Guard against duplicate links
        if ( $field.next( '.ai-redo' ).length ) { return; }

        var $redo = $( '<a>' )
            .addClass( 'ai-redo' )
            .attr( 'href', '#' )
            .attr( 'title', 'Generate a new AI summary' )
            .text( '↺ redo AI summary' )
            .css( {
                display:    'inline-block',
                marginTop:  '4px',
                fontSize:   '0.8em',
                color:      '#36c',
                cursor:     'pointer'
            } )
            .on( 'click', function ( e ) {
                e.preventDefault();
                $field.val( '' );
                autoFill( $field, editorType );
            } );

        $field.after( $redo );
    }

    // ── Step 1: get editor content ─────────────────────────────────────────

    function getEditorText( editorType ) {
        if ( editorType === 'wikitext' ) {
            return $( '#wpTextbox1' ).val() || '';
        }
        // Visual Editor: get plain text from document model
        if ( window.ve && ve.init && ve.init.target ) {
            var surface = ve.init.target.getSurface();
            if ( surface ) {
                try {
                    return surface.getModel().getDocument().data.getText( true );
                } catch ( e ) {
                    mw.log.warn( 'VE getText failed:', e );
                }
            }
        }
        return '';
    }

    // ── Step 2: build prompt using saved wikitext + current editor text ────

    function buildPrompt( editorType ) {
        var title  = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
        var revId  = mw.config.get( 'wgRevisionId' );
        var newTxt = getEditorText( editorType );

        // New page — no previous revision to compare
        if ( !revId ) {
            return $.Deferred().resolve(
                'Family wiki. New page: "' + title + '".\n\n' +
                'Content:\n' + newTxt.slice( 0, 1500 )
            ).promise();
        }

        // Existing page: fetch saved wikitext and compare with current editor text
        return new mw.Api().get( {
            action:  'query',
            titles:  mw.config.get( 'wgPageName' ),
            prop:    'revisions',
            rvprop:  'content',
            rvlimit: 1,
            format:  'json'
        } ).then( function ( d ) {
            var pages   = d.query.pages;
            var page    = pages[ Object.keys( pages )[ 0 ] ];
            var oldText = ( page.revisions && page.revisions[ 0 ][ '*' ] ) || '';

            return 'Family wiki. Page: "' + title + '".\n\n' +
                   'SAVED WIKITEXT (before edit):\n' + oldText.slice( 0, 1400 ) + '\n\n' +
                   'CURRENT EDITOR TEXT (after edit):\n' + newTxt.slice( 0, 1400 );
        } ).catch( function () {
            return 'Family wiki. Page "' + title + '" edited.\n\nEditor text:\n' + newTxt.slice( 0, 1500 );
        } );
    }

    // ── Step 3: call Claude ────────────────────────────────────────────────

    function callClaude( prompt ) {
        return $.ajax( {
            url:         'https://api.anthropic.com/v1/messages',
            method:      'POST',
            contentType: 'application/json',
            headers: {
                'x-api-key':                                 CLAUDE_API_KEY,
                'anthropic-version':                         '2023-06-01',
                'anthropic-dangerous-direct-browser-access': 'true'
            },
            data: JSON.stringify( {
                model:      CLAUDE_MODEL,
                max_tokens: 100,
                system: [
                    'You write concise MediaWiki edit summaries for a family history wiki.',
                    'Reply with ONLY the summary — no quotes, no explanation, no trailing punctuation.',
                    'Maximum 70 characters. Use active voice.',
                    'Examples: "Add birth place for John Gianutsos", "Fix spouse link", "Update death date to 1965"'
                ].join( ' ' ),
                messages: [ { role: 'user', content: prompt } ]
            } )
        } ).then( function ( resp ) {
            return resp.content[ 0 ].text
                       .trim()
                       .replace( /^["']|["']$/g, '' )
                       .slice( 0, 70 );
        }, function ( xhr ) {
            var msg = xhr.responseJSON &&
                      xhr.responseJSON.error &&
                      xhr.responseJSON.error.message;
            return $.Deferred().reject( msg || xhr.statusText ).promise();
        } );
    }

}() );