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

From Family Commonplace Wiki
Revision as of 19:51, 23 May 2026 by FamilyAdmin (talk | contribs) (Install AI edit summary gadget)
(diff) ← Older revision | Latest revision (diff) | Newer revision β†’ (diff)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * Gadget: AI Edit Summary
 *
 * 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 () {
    '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

    // ── Bootstrap ──────────────────────────────────────────────────────────

    // Classic wikitext editor: summary field is present on page load
    $( function () {
        var $field = $( '#wpSummary' );
        if ( $field.length ) {
            injectButton( $field, 'wikitext' );
        }
    } );

    // Visual Editor: save dialog is injected into the DOM later
    mw.hook( 've.activationComplete' ).add( function () {
        var observer = new MutationObserver( function () {
            var $ta = $( '.ve-ui-mwSaveDialog-summary textarea' );
            if ( $ta.length && !$ta.data( 'ai-btn-attached' ) ) {
                $ta.data( 'ai-btn-attached', true );
                injectButton( $ta, 've' );
            }
        } );
        observer.observe( document.body, { childList: true, subtree: true } );
    } );

    // ── Button ─────────────────────────────────────────────────────────────

    function injectButton( $field, editorType ) {
        var $btn = $( '<button>' )
            .attr( 'type', 'button' )
            .html( '&#10024; AI summary' )
            .css( {
                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 () {
                $btn.prop( 'disabled', true ).text( '⏳ Thinking…' );
                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 ───────────────────────────────────────────────────────────

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

    // ── Step 1: get the wikitext being saved ───────────────────────────────

    function getNewWikitext( editorType ) {
        // Classic editor: read the textarea directly
        if ( editorType === 'wikitext' ) {
            return $.Deferred().resolve( $( '#wpTextbox1' ).val() ).promise();
        }

        // Visual Editor: serialize the in-memory document via the API
        if ( !window.ve || !ve.init || !ve.init.target ) {
            return $.Deferred().reject( 'VisualEditor not ready' ).promise();
        }
        var surface = ve.init.target.getSurface();
        if ( !surface ) {
            return $.Deferred().reject( 'VE surface not available' ).promise();
        }

        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();
        }
    }

    // ── Step 2: build a prompt from the diff ──────────────────────────────

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

        // New page β€” no previous revision to diff against
        if ( !revId ) {
            return $.Deferred().resolve(
                '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( {
            action: 'compare',
            fromrev: revId,
            totext:  newText,
            topst:   1,
            prop:    'diff',
            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 ) {
        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)';
    }

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

    function callClaude( prompt ) {
        if ( !prompt.trim() ) {
            return $.Deferred().reject( 'Nothing to summarise' ).promise();
        }

        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 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 } ]
            } )
        } ).then( function ( resp ) {
            return resp.content[ 0 ].text
                       .trim()
                       .replace( /^["']|["']$/g, '' )
                       .slice( 0, 70 );
        }, function ( xhr ) {
            var err = xhr.responseJSON &&
                      xhr.responseJSON.error &&
                      xhr.responseJSON.error.message;
            return $.Deferred().reject( err || xhr.statusText ).promise();
        } );
    }

}() );