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
Install AI edit summary gadget
 
Fix timing: check if VE already active on gadget load
Line 1: Line 1:
/**
/**
  * Gadget: AI Edit Summary
  * Gadget: AI Edit Summary (auto-fill)
  *
  * Fills the summary field automatically when the save dialog opens.
* Adds an "✨ AI summary" button to the MediaWiki save dialog.
* When clicked it diffs the current edit against the saved revision,
* sends the diff to Claude, and fills the summary field.
*
* Works in both VisualEditor and the classic wikitext editor.
  */
  */
( function () {
( function () {
     'use strict';
     'use strict';


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


     // ── Bootstrap ──────────────────────────────────────────────────────────
     function setupVEObserver() {
 
        // Immediately check if save dialog is already open
    // Classic wikitext editor: summary field is present on page load
         var $ta = $( '.ve-ui-mwSaveDialog-summary textarea' );
    $( function () {
         if ( $ta.length && !$ta.data( 'ai-triggered' ) ) {
         var $field = $( '#wpSummary' );
             $ta.data( 'ai-triggered', true );
         if ( $field.length ) {
            autoFill( $ta, 've' );
             injectButton( $field, 'wikitext' );
            addRedoLink( $ta, 've' );
         }
         }
    } );
        // Watch for it to open in the future
 
    // Visual Editor: save dialog is injected into the DOM later
    mw.hook( 've.activationComplete' ).add( function () {
         var observer = new MutationObserver( function () {
         var observer = new MutationObserver( function () {
             var $ta = $( '.ve-ui-mwSaveDialog-summary textarea' );
             var $ta2 = $( '.ve-ui-mwSaveDialog-summary textarea' );
             if ( $ta.length && !$ta.data( 'ai-btn-attached' ) ) {
             if ( $ta2.length && !$ta2.data( 'ai-triggered' ) ) {
                 $ta.data( 'ai-btn-attached', true );
                 $ta2.data( 'ai-triggered', true );
                 injectButton( $ta, 've' );
                 autoFill( $ta2, 've' );
                addRedoLink( $ta2, 've' );
             }
             }
         } );
         } );
         observer.observe( document.body, { childList: true, subtree: true } );
         observer.observe( document.body, { childList: true, subtree: true } );
    }
    // If VE is already active, set up immediately
    if ( window.ve && ve.init && ve.init.target ) {
        setupVEObserver();
    }
    // Also hook for future VE activations
    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' ); } );
     } );
     } );


    // ── Button ─────────────────────────────────────────────────────────────
     function autoFill( $field, editorType ) {
 
        if ( $field.val().trim() ) { return; }
     function injectButton( $field, editorType ) {
         var orig = $field.attr( 'placeholder' ) || '';
         var $btn = $( '<button>' )
        $field.attr( 'placeholder', '\u2728 Writing AI summary\u2026' );
            .attr( 'type', 'button' )
        generateSummary( editorType )
            .html( '&#10024; AI summary' )
             .then( function ( s ) {
             .css( {
                 if ( !$field.val().trim() ) { $field.val( s ).trigger( 'input change' ); }
                 display:      'inline-block',
                marginTop:    '5px',
                padding:      '3px 12px',
                fontSize:    '0.85em',
                fontWeight:  'bold',
                cursor:      'pointer',
                background:  'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
                color:        '#fff',
                border:      'none',
                borderRadius: '3px',
                lineHeight:  '1.6'
             } )
             } )
             .on( 'click', function () {
             .catch( function ( e ) { mw.log.warn( 'AI summary:', e ); } )
                $btn.prop( 'disabled', true ).text( '⏳ Thinking…' );
            .always( function () { $field.attr( 'placeholder', orig ); } );
                generateSummary( editorType )
    }
                    .then( function ( summary ) {
                        $field.val( summary ).trigger( 'input change' );
                        $btn.prop( 'disabled', false ).html( '&#10024; AI summary' );
                    } )
                    .catch( function ( err ) {
                        mw.notify( 'AI summary failed: ' + err,
                                  { type: 'error', autoHideSeconds: 8 } );
                        $btn.prop( 'disabled', false ).html( '&#10024; AI summary' );
                    } );
            } );


         $field.after( $btn );
    function addRedoLink( $field, editorType ) {
         $( '<a>' ).attr( 'href', '#' ).text( '\u21ba redo AI summary' )
            .css( { display: 'inline-block', marginTop: '4px', fontSize: '0.8em', color: '#36c' } )
            .on( 'click', function ( e ) {
                e.preventDefault(); $field.val( '' ); autoFill( $field, editorType );
            } ).insertAfter( $field );
     }
     }
    // ── Pipeline ───────────────────────────────────────────────────────────


     function generateSummary( editorType ) {
     function generateSummary( editorType ) {
         return getNewWikitext( editorType )
         return getNewWikitext( editorType ).then( buildPrompt ).then( callClaude );
            .then( buildPrompt )
            .then( callClaude );
     }
     }
    // ── Step 1: get the wikitext being saved ───────────────────────────────


     function getNewWikitext( editorType ) {
     function getNewWikitext( editorType ) {
        // Classic editor: read the textarea directly
         if ( editorType === 'wikitext' ) {
         if ( editorType === 'wikitext' ) {
             return $.Deferred().resolve( $( '#wpTextbox1' ).val() ).promise();
             return $.Deferred().resolve( $( '#wpTextbox1' ).val() ).promise();
         }
         }
        // Visual Editor: serialize the in-memory document via the API
         if ( !window.ve || !ve.init || !ve.init.target ) {
         if ( !window.ve || !ve.init || !ve.init.target ) {
             return $.Deferred().reject( 'VisualEditor not ready' ).promise();
             return $.Deferred().reject( 'VE not ready' ).promise();
         }
         }
         var surface = ve.init.target.getSurface();
         var surface = ve.init.target.getSurface();
         if ( !surface ) {
         if ( !surface ) { return $.Deferred().reject( 'no surface' ).promise(); }
            return $.Deferred().reject( 'VE surface not available' ).promise();
        }
 
         try {
         try {
             var htmlDoc    = ve.dm.converter.getDomFromModel(
             var html = new XMLSerializer().serializeToString(
                                surface.getModel().getDocument() );
                ve.dm.converter.getDomFromModel( surface.getModel().getDocument() ) );
            var serialized = new XMLSerializer().serializeToString( htmlDoc );
 
             return new mw.Api().post( {
             return new mw.Api().post( {
                 action: 'visualeditor',
                 action: 'visualeditor', paction: 'serialize',
                paction: 'serialize',
                 page: mw.config.get( 'wgPageName' ), html: html, format: 'json'
                 page:   mw.config.get( 'wgPageName' ),
             } ).then( function ( d ) { return ( d.visualeditor && d.visualeditor.content ) || ''; } );
                html:   serialized,
         } catch ( e ) { return $.Deferred().reject( String( e ) ).promise(); }
                format: 'json'
             } ).then( function ( data ) {
                return ( data.visualeditor && data.visualeditor.content ) || '';
            } );
         } catch ( e ) {
            return $.Deferred().reject( 'Could not read VE document: ' + e ).promise();
        }
     }
     }
    // ── Step 2: build a prompt from the diff ──────────────────────────────


     function buildPrompt( newText ) {
     function buildPrompt( newText ) {
         var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
         var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
         var revId = mw.config.get( 'wgRevisionId' );
         var revId = mw.config.get( 'wgRevisionId' );
        // New page — no previous revision to diff against
         if ( !revId ) {
         if ( !revId ) {
             return $.Deferred().resolve(
             return $.Deferred().resolve( 'Family wiki. New page: "' + title + '".\n\n' + newText.slice( 0, 1200 ) ).promise();
                'Family wiki. New page created: "' + title + '".\n\n' +
                'Content (first 1200 chars):\n' + newText.slice( 0, 1200 )
            ).promise();
         }
         }
        // Existing page — fetch a diff
         return new mw.Api().get( {
         return new mw.Api().get( {
             action: 'compare',
             action: 'compare', fromrev: revId, totext: newText, topst: 1, prop: 'diff', format: 'json'
            fromrev: revId,
         } ).then( function ( d ) {
            totext: newText,
             var diff = parseDiff( ( d.compare && d.compare.body ) || '' );
            topst:   1,
             return 'Family wiki. Page: "' + title + '".\n\nDiff:\n' + diff;
            prop:   'diff',
         } ).catch( function () { return 'Family wiki. Page "' + title + '" was edited.'; } );
            format: 'json'
         } ).then( function ( data ) {
             var diffText = parseDiff( ( data.compare && data.compare.body ) || '' );
             return 'Family wiki. Page: "' + title + '".\n\n' +
                  'Diff (lines prefixed + added / - removed):\n' + diffText;
         } ).catch( function () {
            // If diff fails, still give Claude something to work with
            return 'Family wiki. Page "' + title + '" was edited.';
        } );
     }
     }


    /** Strip HTML from the diff table and return plain-text +/- lines. */
     function parseDiff( html ) {
     function parseDiff( html ) {
         var $d   = $( '<div>' ).html( html );
         var $d = $( '<div>' ).html( html ), lines = [];
        var lines = [];
         $d.find( 'tr' ).each( function () {
         $d.find( 'tr' ).each( function () {
             var $tr  = $( this );
             var del = $( this ).find( 'td.diff-deletedline' ).text().trim();
            var del  = $tr.find( 'td.diff-deletedline' ).text().trim();
             var add = $( this ).find( 'td.diff-addedline' ).text().trim();
             var add = $tr.find( 'td.diff-addedline'   ).text().trim();
             if ( del ) { lines.push( '- ' + del ); }
             if ( del ) { lines.push( '- ' + del ); }
             if ( add ) { lines.push( '+ ' + add ); }
             if ( add ) { lines.push( '+ ' + add ); }
         } );
         } );
         return lines.join( '\n' ).slice( 0, 2500 ) || '(no textual changes detected)';
         return lines.join( '\n' ).slice( 0, 2500 ) || '(no changes)';
     }
     }
    // ── Step 3: call Claude ────────────────────────────────────────────────


     function callClaude( prompt ) {
     function callClaude( prompt ) {
        if ( !prompt.trim() ) {
            return $.Deferred().reject( 'Nothing to summarise' ).promise();
        }
         return $.ajax( {
         return $.ajax( {
             url:         'https://api.anthropic.com/v1/messages',
             url: 'https://api.anthropic.com/v1/messages', method: 'POST',
            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,
                 model: CLAUDE_MODEL, max_tokens: 100,
                max_tokens: 100,
                 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".',
                 system: [
                    'You write concise MediaWiki edit summaries for a family history wiki.',
                    'Reply with ONLY the summary — no quotes, no explanation, no punctuation at the end.',
                    'Maximum 70 characters. Use active voice and plain English.',
                    'Good examples:',
                    '  Add birth place for John Gianutsos',
                    '  Fix spouse link on Nicole Morbillo page',
                    '  Update death date to 1965',
                    '  Add children section',
                    '  Correct spelling of Evtichia'
                ].join( '\n' ),
                 messages: [ { role: 'user', content: prompt } ]
                 messages: [ { role: 'user', content: prompt } ]
             } )
             } )
         } ).then( function ( resp ) {
         } ).then( function ( r ) {
             return resp.content[ 0 ].text
             return r.content[ 0 ].text.trim().replace( /^["']|["']$/g, '' ).slice( 0, 70 );
                      .trim()
                      .replace( /^["']|["']$/g, '' )
                      .slice( 0, 70 );
         }, function ( xhr ) {
         }, function ( xhr ) {
             var err = xhr.responseJSON &&
             var m = xhr.responseJSON && xhr.responseJSON.error && xhr.responseJSON.error.message;
                      xhr.responseJSON.error &&
             return $.Deferred().reject( m || xhr.statusText ).promise();
                      xhr.responseJSON.error.message;
             return $.Deferred().reject( err || xhr.statusText ).promise();
         } );
         } );
     }
     }


}() );
}() );

Revision as of 20:24, 23 May 2026

/**
 * Gadget: AI Edit Summary (auto-fill)
 * Fills the summary field automatically when the save dialog opens.
 */
( function () {
    'use strict';

    var CLAUDE_API_KEY = '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';
    var CLAUDE_MODEL   = 'claude-haiku-4-5-20251001';

    function setupVEObserver() {
        // Immediately check if save dialog is already open
        var $ta = $( '.ve-ui-mwSaveDialog-summary textarea' );
        if ( $ta.length && !$ta.data( 'ai-triggered' ) ) {
            $ta.data( 'ai-triggered', true );
            autoFill( $ta, 've' );
            addRedoLink( $ta, 've' );
        }
        // Watch for it to open in the future
        var observer = new MutationObserver( function () {
            var $ta2 = $( '.ve-ui-mwSaveDialog-summary textarea' );
            if ( $ta2.length && !$ta2.data( 'ai-triggered' ) ) {
                $ta2.data( 'ai-triggered', true );
                autoFill( $ta2, 've' );
                addRedoLink( $ta2, 've' );
            }
        } );
        observer.observe( document.body, { childList: true, subtree: true } );
    }

    // If VE is already active, set up immediately
    if ( window.ve && ve.init && ve.init.target ) {
        setupVEObserver();
    }
    // Also hook for future VE activations
    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' ); } );
    } );

    function autoFill( $field, editorType ) {
        if ( $field.val().trim() ) { return; }
        var orig = $field.attr( 'placeholder' ) || '';
        $field.attr( 'placeholder', '\u2728 Writing AI summary\u2026' );
        generateSummary( editorType )
            .then( function ( s ) {
                if ( !$field.val().trim() ) { $field.val( s ).trigger( 'input change' ); }
            } )
            .catch( function ( e ) { mw.log.warn( 'AI summary:', e ); } )
            .always( function () { $field.attr( 'placeholder', orig ); } );
    }

    function addRedoLink( $field, editorType ) {
        $( '<a>' ).attr( 'href', '#' ).text( '\u21ba redo AI summary' )
            .css( { display: 'inline-block', marginTop: '4px', fontSize: '0.8em', color: '#36c' } )
            .on( 'click', function ( e ) {
                e.preventDefault(); $field.val( '' ); autoFill( $field, editorType );
            } ).insertAfter( $field );
    }

    function generateSummary( editorType ) {
        return getNewWikitext( editorType ).then( buildPrompt ).then( callClaude );
    }

    function getNewWikitext( editorType ) {
        if ( editorType === 'wikitext' ) {
            return $.Deferred().resolve( $( '#wpTextbox1' ).val() ).promise();
        }
        if ( !window.ve || !ve.init || !ve.init.target ) {
            return $.Deferred().reject( 'VE not ready' ).promise();
        }
        var surface = ve.init.target.getSurface();
        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 ) {
        var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
        var revId = mw.config.get( 'wgRevisionId' );
        if ( !revId ) {
            return $.Deferred().resolve( 'Family wiki. New page: "' + title + '".\n\n' + newText.slice( 0, 1200 ) ).promise();
        }
        return new mw.Api().get( {
            action: 'compare', fromrev: revId, totext: newText, topst: 1, prop: 'diff', format: 'json'
        } ).then( function ( d ) {
            var diff = parseDiff( ( d.compare && d.compare.body ) || '' );
            return 'Family wiki. Page: "' + title + '".\n\nDiff:\n' + diff;
        } ).catch( function () { return 'Family wiki. Page "' + title + '" was edited.'; } );
    }

    function parseDiff( html ) {
        var $d = $( '<div>' ).html( html ), lines = [];
        $d.find( 'tr' ).each( function () {
            var del = $( this ).find( 'td.diff-deletedline' ).text().trim();
            var add = $( this ).find( 'td.diff-addedline' ).text().trim();
            if ( del ) { lines.push( '- ' + del ); }
            if ( add ) { lines.push( '+ ' + add ); }
        } );
        return lines.join( '\n' ).slice( 0, 2500 ) || '(no changes)';
    }

    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. Max 70 chars. Active voice. Examples: "Add birth place for John Gianutsos", "Fix spouse link", "Update death date".',
                messages: [ { role: 'user', content: prompt } ]
            } )
        } ).then( function ( r ) {
            return r.content[ 0 ].text.trim().replace( /^["']|["']$/g, '' ).slice( 0, 70 );
        }, function ( xhr ) {
            var m = xhr.responseJSON && xhr.responseJSON.error && xhr.responseJSON.error.message;
            return $.Deferred().reject( m || xhr.statusText ).promise();
        } );
    }

}() );