MediaWiki:Gadget-aiSummary.js: Difference between revisions
Appearance
FamilyAdmin (talk | contribs) Install AI edit summary gadget |
FamilyAdmin (talk | contribs) 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) | ||
* | * | ||
* | * 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 () { | ||
| Line 12: | Line 10: | ||
// ── Configuration ────────────────────────────────────────────────────── | // ── 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'; // fast & cheap | 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' ); | |||
var $ | if ( $ta.length && !$ta.data( 'ai-done' ) ) { | ||
if ( $ | $ta.data( 'ai-done', true ); | ||
autoFill( $ta, 've' ); | |||
addRedoLink( $ta, 've' ); | |||
} | } | ||
var observer = new MutationObserver( function () { | var observer = new MutationObserver( function () { | ||
var $ | var $t = $( '.ve-ui-mwSaveDialog-summary textarea' ); | ||
if ( $ | 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 } ); | 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 | function autoFill( $field, editorType ) { | ||
var | // 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; } | |||
function | 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 | // ── Step 1: get editor content ───────────────────────────────────────── | ||
function | function getEditorText( editorType ) { | ||
if ( editorType === 'wikitext' ) { | if ( editorType === 'wikitext' ) { | ||
return | return $( '#wpTextbox1' ).val() || ''; | ||
} | } | ||
// Visual Editor: get plain text from document model | |||
// Visual Editor: | if ( window.ve && ve.init && ve.init.target ) { | ||
if ( | 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 | // ── Step 2: build prompt using saved wikitext + current editor text ──── | ||
function buildPrompt( | 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 | // New page — no previous revision to compare | ||
if ( !revId ) { | if ( !revId ) { | ||
return $.Deferred().resolve( | return $.Deferred().resolve( | ||
'Family wiki. New page | 'Family wiki. New page: "' + title + '".\n\n' + | ||
'Content | 'Content:\n' + newTxt.slice( 0, 1500 ) | ||
).promise(); | ).promise(); | ||
} | } | ||
// Existing page | // Existing page: fetch saved wikitext and compare with current editor text | ||
return new mw.Api().get( { | return new mw.Api().get( { | ||
action: ' | action: 'query', | ||
titles: mw.config.get( 'wgPageName' ), | |||
prop: 'revisions', | |||
rvprop: 'content', | |||
rvlimit: 1, | |||
format: 'json' | format: 'json' | ||
} ).then( function ( | } ).then( function ( d ) { | ||
var | 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' + | ||
' | '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 () { | ||
return 'Family wiki. Page "' + title + '" edited.\n\nEditor text:\n' + newTxt.slice( 0, 1500 ); | |||
return 'Family wiki. Page "' + title + '" | |||
} ); | } ); | ||
} | } | ||
| Line 166: | Line 162: | ||
function callClaude( prompt ) { | function callClaude( prompt ) { | ||
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': | 'x-api-key': CLAUDE_API_KEY, | ||
'anthropic-version': | '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 | 'Reply with ONLY the summary — no quotes, no explanation, no trailing punctuation.', | ||
'Maximum 70 characters. Use active voice | 'Maximum 70 characters. Use active voice.', | ||
' | 'Examples: "Add birth place for John Gianutsos", "Fix spouse link", "Update death date to 1965"' | ||
].join( ' ' ), | |||
].join( ' | |||
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 | var msg = xhr.responseJSON && | ||
xhr.responseJSON.error && | xhr.responseJSON.error && | ||
xhr.responseJSON.error.message; | xhr.responseJSON.error.message; | ||
return $.Deferred().reject( | 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();
} );
}
}() );