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
 
Install AI edit summary gadget
 
(One intermediate revision by the same user not shown)
Line 1: Line 1:
/**
/**
  * Gadget: AI Edit Summary
  * Gadget: AI Edit Summary (auto-fill)
  *
  *
  * Adds an "✨ AI summary" button to the MediaWiki save dialog.
  * Automatically fills the edit summary field with a Claude-generated
* When clicked it diffs the current edit against the saved revision,
  * description the moment the save dialog opens. Works in VisualEditor
  * sends the diff to Claude, and fills the summary field.
  * and the classic wikitext editor.
  *
* Works in both VisualEditor and the classic wikitext editor.
  */
  */
( function () {
( function () {
Line 12: Line 10:


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


     // ── Bootstrap ──────────────────────────────────────────────────────────
     // ── Visual Editor ──────────────────────────────────────────────────────
 
     function setupVEObserver() {
    // Classic wikitext editor: summary field is present on page load
        // Check immediately in case save dialog already open
     $( function () {
         var $ta = $( '.ve-ui-mwSaveDialog-summary textarea' );
         var $field = $( '#wpSummary' );
         if ( $ta.length && !$ta.data( 'ai-done' ) ) {
         if ( $field.length ) {
             $ta.data( 'ai-done', true );
             injectButton( $field, 'wikitext' );
            autoFill( $ta, 've' );
            addRedoLink( $ta, 've' );
         }
         }
    } );


    // 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 $t = $( '.ve-ui-mwSaveDialog-summary textarea' );
             if ( $ta.length && !$ta.data( 'ai-btn-attached' ) ) {
             if ( $t.length && !$t.data( 'ai-done' ) ) {
                 $ta.data( 'ai-btn-attached', true );
                 $t.data( 'ai-done', true );
                 injectButton( $ta, 've' );
                 autoFill( $t, 've' );
                addRedoLink( $t, 've' );
             }
             }
         } );
         } );
         observer.observe( document.body, { childList: true, subtree: true } );
         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' );
        } );
     } );
     } );


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


     function injectButton( $field, editorType ) {
     function autoFill( $field, editorType ) {
         var $btn = $( '<button>' )
        // Don't overwrite something the user already typed
            .attr( 'type', 'button' )
        if ( $field.val().trim() ) { return; }
             .html( '&#10024; AI summary' )
 
             .css( {
         var origPlaceholder = $field.attr( 'placeholder' ) || '';
                 display:      'inline-block',
        $field.attr( 'placeholder', '✨ Writing AI summary…' );
                 marginTop:    '5px',
 
                padding:      '3px 12px',
        buildPrompt( editorType )
                 fontSize:    '0.85em',
             .then( callClaude )
                fontWeight:  'bold',
             .then( function ( summary ) {
                cursor:      'pointer',
                 // Only fill if still empty
                background:  'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
                 if ( !$field.val().trim() ) {
                 color:        '#fff',
                    $field.val( summary ).trigger( 'input change' );
                border:       'none',
                 }
                borderRadius: '3px',
            } )
                lineHeight:  '1.6'
            .catch( function ( err ) {
                 mw.log.warn( 'AI summary error:', err );
             } )
             } )
             .on( 'click', function () {
             .always( function () {
                 $btn.prop( 'disabled', true ).text( '⏳ Thinking…' );
                 $field.attr( 'placeholder', origPlaceholder );
                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 );
     }
     }


     // ── Pipeline ───────────────────────────────────────────────────────────
     // 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; }


     function generateSummary( editorType ) {
        var $redo = $( '<a>' )
        return getNewWikitext( editorType )
            .addClass( 'ai-redo' )
            .then( buildPrompt )
            .attr( 'href', '#' )
             .then( callClaude );
            .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 the wikitext being saved ───────────────────────────────
     // ── Step 1: get editor content ─────────────────────────────────────────


     function getNewWikitext( editorType ) {
     function getEditorText( editorType ) {
        // Classic editor: read the textarea directly
         if ( editorType === 'wikitext' ) {
         if ( editorType === 'wikitext' ) {
             return $.Deferred().resolve( $( '#wpTextbox1' ).val() ).promise();
             return $( '#wpTextbox1' ).val() || '';
         }
         }
 
         // Visual Editor: get plain text from document model
         // 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 ) {
             var surface = ve.init.target.getSurface();
             return $.Deferred().reject( 'VisualEditor not ready' ).promise();
            if ( surface ) {
        }
                try {
        var surface = ve.init.target.getSurface();
                    return surface.getModel().getDocument().data.getText( true );
        if ( !surface ) {
                 } catch ( e ) {
            return $.Deferred().reject( 'VE surface not available' ).promise();
                    mw.log.warn( 'VE getText failed:', e );
        }
                }
 
            }
        try {
            var htmlDoc    = ve.dm.converter.getDomFromModel(
                                surface.getModel().getDocument() );
            var serialized = new XMLSerializer().serializeToString( htmlDoc );
 
            return new mw.Api().post( {
                 action:  'visualeditor',
                paction: 'serialize',
                page:    mw.config.get( 'wgPageName' ),
                html:    serialized,
                format:  'json'
            } ).then( function ( data ) {
                return ( data.visualeditor && data.visualeditor.content ) || '';
            } );
        } catch ( e ) {
            return $.Deferred().reject( 'Could not read VE document: ' + e ).promise();
         }
         }
        return '';
     }
     }


     // ── Step 2: build a prompt from the diff ──────────────────────────────
     // ── Step 2: build prompt using saved wikitext + current editor text ────


     function buildPrompt( newText ) {
     function buildPrompt( editorType ) {
         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' );
        var newTxt = getEditorText( editorType );


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


         // Existing page fetch a diff
         // Existing page: fetch saved wikitext and compare with current editor text
         return new mw.Api().get( {
         return new mw.Api().get( {
             action: 'compare',
             action: 'query',
             fromrev: revId,
             titles: mw.config.get( 'wgPageName' ),
             totext: newText,
             prop:   'revisions',
             topst:   1,
             rvprop: 'content',
             prop:   'diff',
             rvlimit: 1,
             format:  'json'
             format:  'json'
         } ).then( function ( data ) {
         } ).then( function ( d ) {
             var diffText = parseDiff( ( data.compare && data.compare.body ) || '' );
             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' +
             return 'Family wiki. Page: "' + title + '".\n\n' +
                   'Diff (lines prefixed + added / - removed):\n' + diffText;
                   'SAVED WIKITEXT (before edit):\n' + oldText.slice( 0, 1400 ) + '\n\n' +
                  'CURRENT EDITOR TEXT (after edit):\n' + newTxt.slice( 0, 1400 );
         } ).catch( function () {
         } ).catch( function () {
            // If diff fails, still give Claude something to work with
             return 'Family wiki. Page "' + title + '" edited.\n\nEditor text:\n' + newTxt.slice( 0, 1500 );
             return 'Family wiki. Page "' + title + '" was edited.';
         } );
         } );
    }
    /** Strip HTML from the diff table and return plain-text +/- lines. */
    function parseDiff( html ) {
        var $d  = $( '<div>' ).html( html );
        var lines = [];
        $d.find( 'tr' ).each( function () {
            var $tr  = $( this );
            var del  = $tr.find( 'td.diff-deletedline' ).text().trim();
            var add  = $tr.find( 'td.diff-addedline'  ).text().trim();
            if ( del ) { lines.push( '- ' + del ); }
            if ( add ) { lines.push( '+ ' + add ); }
        } );
        return lines.join( '\n' ).slice( 0, 2500 ) || '(no textual changes detected)';
     }
     }


Line 166: Line 162:


     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',
Line 175: Line 167:
             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'
             },
             },
Line 184: Line 176:
                 system: [
                 system: [
                     'You write concise MediaWiki edit summaries for a family history wiki.',
                     '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.',
                     'Reply with ONLY the summary — no quotes, no explanation, no trailing punctuation.',
                     'Maximum 70 characters. Use active voice and plain English.',
                     'Maximum 70 characters. Use active voice.',
                     'Good examples:',
                     'Examples: "Add birth place for John Gianutsos", "Fix spouse link", "Update death date to 1965"'
                    '  Add birth place for John Gianutsos',
                 ].join( ' ' ),
                    '  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 } ]
             } )
             } )
Line 201: Line 188:
                       .slice( 0, 70 );
                       .slice( 0, 70 );
         }, function ( xhr ) {
         }, function ( xhr ) {
             var err = xhr.responseJSON &&
             var msg = xhr.responseJSON &&
                       xhr.responseJSON.error &&
                       xhr.responseJSON.error &&
                       xhr.responseJSON.error.message;
                       xhr.responseJSON.error.message;
             return $.Deferred().reject( err || xhr.statusText ).promise();
             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();
        } );
    }

}() );