Making WordPress.org

Changeset 12633


Ignore:
Timestamp:
06/08/2023 03:07:08 AM (3 years ago)
Author:
akirk
Message:

Translate: Use the streaming review response from ChatGPT

Location:
sites/trunk/wordpress.org/public_html/wp-content/plugins/gp-translation-helpers
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/gp-translation-helpers/includes/class-gp-translation-helpers.php

    r12629 r12633  
    402402            '$gp_comment_feedback_settings',
    403403            array(
    404                 'url'             => admin_url( 'admin-ajax.php' ),
    405                 'nonce'           => wp_create_nonce( 'gp_comment_feedback' ),
    406                 'locale_slug'     => $translation_set['locale_slug'],
    407                 'language'        => $gp_locale ? $gp_locale->english_name : 'Unknown',
    408                 'has_openai_key'  => !! apply_filters( 'gp_get_openai_key', null ),
    409                 'comment_reasons' => Helper_Translation_Discussion::get_comment_reasons( $translation_set['locale_slug'] ),
     404                'url'                => admin_url( 'admin-ajax.php' ),
     405                'nonce'              => wp_create_nonce( 'gp_comment_feedback' ),
     406                'locale_slug'        => $translation_set['locale_slug'],
     407                'language'           => $gp_locale ? $gp_locale->english_name : 'Unknown',
     408                'openai_key'         => apply_filters( 'gp_get_openai_key', null ),
     409                'openai_temperature' => apply_filters( 'gp_get_openai_temperature', 0.8 ),
     410                'comment_reasons'    => Helper_Translation_Discussion::get_comment_reasons( $translation_set['locale_slug'] ),
    410411            )
    411412        );
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/gp-translation-helpers/js/editor.js

    r12629 r12633  
    1 /* global $gp, $gp_translation_helpers_editor, wpApiSettings, $gp_comment_feedback_settings, console, $gp_editor_options  */
     1/* global $gp, $gp_translation_helpers_editor, wpApiSettings, $gp_comment_feedback_settings, console, $gp_editor_options, EventSource */
    22/* eslint camelcase: "off" */
    33jQuery( function( $ ) {
    4     var focusedRowId = '';
     4    let focusedRowId = '';
    55    // When a user clicks on a sidebar tab, the visible tab and div changes.
    66    $gp.editor.table.on( 'click', '.sidebar-tabs li', function() {
    7         var tab = $( this );
    8         var tabId = tab.attr( 'data-tab' );
    9         var divId = tabId.replace( 'tab', 'div' );
    10         var originalId = tabId.replace( /[^\d-]/g, '' ).replace( /^-+/g, '' );
     7        const tab = $( this );
     8        const tabId = tab.attr( 'data-tab' );
     9        const divId = tabId.replace( 'tab', 'div' );
     10        const originalId = tabId.replace( /[^\d-]/g, '' ).replace( /^-+/g, '' );
    1111        change_visible_tab( tab );
    1212        change_visible_div( divId, originalId );
     
    1717    // divs with the content) for the right sidebar are updated.
    1818    $gp.editor.table.on( 'focus', 'tr.editor textarea.foreign-text', function() {
    19         var tr = $( this ).closest( 'tr.editor' );
    20         var rowId = tr.attr( 'row' );
    21         var translation_status = tr.find( '.panel-header' ).find( 'span' ).html();
     19        const tr = $( this ).closest( 'tr.editor' );
     20        const rowId = tr.attr( 'row' );
     21        const translation_status = tr.find( '.panel-header' ).find( 'span' ).html();
    2222
    2323        if ( focusedRowId === rowId ) {
     
    2626        focusedRowId = rowId;
    2727        loadTabsAndDivs( tr );
    28         if ( $gp_comment_feedback_settings.has_openai_key && $gp_editor_options.can_approve && ( 'waiting' === translation_status || 'fuzzy' === translation_status ) ) {
     28        if ( $gp_comment_feedback_settings.openai_key && $gp_editor_options.can_approve && ( 'waiting' === translation_status || 'fuzzy' === translation_status ) ) {
    2929            fetchOpenAIReviewResponse( rowId, tr, false );
    3030        } else {
     
    3434
    3535    $gp.editor.table.on( 'click', 'a.retry-auto-review', function( event ) {
    36         var tr = $( this ).closest( 'tr.editor' );
    37         var rowId = tr.attr( 'row' );
     36        const tr = $( this ).closest( 'tr.editor' );
     37        const rowId = tr.attr( 'row' );
    3838        event.preventDefault();
    3939        tr.find( '.openai-review .auto-review-result' ).html( '' );
     
    4444    // Shows/hides the reply form for a comment in the discussion.
    4545    $gp.editor.table.on( 'click', 'a.comment-reply-link', function( event ) {
    46         var commentId = $( this ).attr( 'data-commentid' );
     46        const commentId = $( this ).attr( 'data-commentid' );
    4747        event.preventDefault();
    4848        $( '#comment-reply-' + commentId ).toggle().find( 'textarea' ).focus();
     
    5858    // to avoid creating empty posts (without comments).
    5959    function createShadowPost( formdata, submitComment ) {
    60         var data = {
     60        const data = {
    6161            action: 'create_shadow_post',
    6262            data: formdata,
     
    6868                type: 'POST',
    6969                url: wpApiSettings.admin_ajax_url,
    70                 data: data,
     70                data,
    7171            }
    7272        ).done(
     
    8080    // Sends the new comment or the reply to an existing comment.
    8181    $gp.editor.table.on( 'submit', '.meta.discussion .comment-form', function( e ) {
    82         var $commentform = $( e.target );
    83         var postId = $commentform.attr( 'id' ).split( '-' )[ 1 ];
    84         var divDiscussion = $commentform.closest( '.meta.discussion' );
    85         var rowId = divDiscussion.attr( 'data-row-id' );
    86         var requestUrl = $gp_translation_helpers_editor.translation_helper_url + rowId + '?nohc';
    87 
    88         var submitComment = function( formdata ) {
     82        const $commentform = $( e.target );
     83        const postId = $commentform.attr( 'id' ).split( '-' )[ 1 ];
     84        const divDiscussion = $commentform.closest( '.meta.discussion' );
     85        const rowId = divDiscussion.attr( 'data-row-id' );
     86        const requestUrl = $gp_translation_helpers_editor.translation_helper_url + rowId + '?nohc';
     87
     88        const submitComment = function( formdata ) {
    8989            $.ajax( {
    9090                url: wpApiSettings.root + 'wp/v2/comments',
    9191                method: 'POST',
    92                 beforeSend: function( xhr ) {
     92                beforeSend( xhr ) {
    9393                    xhr.setRequestHeader( 'X-WP-Nonce', wpApiSettings.nonce );
    9494                },
     
    108108        };
    109109
    110         var formdata = {
     110        const formdata = {
    111111            content: $commentform.find( 'textarea[name=comment]' ).val(),
    112112            parent: $commentform.find( 'input[name=comment_parent]' ).val(),
     
    148148    // Copies the translation from another language to the current translation.
    149149    $gp.editor.table.on( 'click', 'button.sidebar-other-locales', function() {
    150         var textToCopy = $( this ).closest( 'li' ).find( 'a' ).text();
    151         var textareaToPaste = $( this ).closest( '.editor' ).find( 'textarea.foreign-text' );
    152         var selectionStart = textareaToPaste.get( 0 ).selectionStart;
    153         var selectionEnd = textareaToPaste.get( 0 ).selectionEnd;
    154         var textToCopyLength = textToCopy.length;
     150        const textToCopy = $( this ).closest( 'li' ).find( 'a' ).text();
     151        const textareaToPaste = $( this ).closest( '.editor' ).find( 'textarea.foreign-text' );
     152        let selectionStart = textareaToPaste.get( 0 ).selectionStart;
     153        let selectionEnd = textareaToPaste.get( 0 ).selectionEnd;
     154        const textToCopyLength = textToCopy.length;
    155155        textareaToPaste.val( textareaToPaste.val().substring( 0, selectionStart ) +
    156156            textToCopy +
     
    168168    // table has only one, so with the double click we load the content sidebar.
    169169    // eslint-disable-next-line vars-on-top
    170     var previewRows = $gp.editor.table.find( 'tr.preview' );
     170    const previewRows = $gp.editor.table.find( 'tr.preview' );
    171171    if ( 1 === previewRows.length ) {
    172172        $( 'tr.preview td' ).trigger( 'dblclick' );
     
    179179     */
    180180    function change_visible_tab( tab ) {
    181         var tabId = tab.attr( 'data-tab' );
     181        const tabId = tab.attr( 'data-tab' );
    182182        tab.siblings().removeClass( 'current' );
    183183        tab.parents( '.sidebar-tabs ' ).find( '.helper' ).removeClass( 'current' );
     
    207207     */
    208208    function add_copy_button( sidebarDiv ) {
    209         var lis = $( sidebarDiv + ' .other-locales li' );
     209        const lis = $( sidebarDiv + ' .other-locales li' );
    210210        lis.each( function() {
    211             var html = $( this ).html();
     211            let html = $( this ).html();
    212212            html += '<button class="sidebar-other-locales button is-small copy-suggestion"> Copy </button>';
    213213            $( this ).html( html );
     
    221221     */
    222222    function loadTabsAndDivs( element ) {
    223         var originalId = element.closest( 'tr' ).attr( 'id' ).substring( 7 );
    224         var requestUrl = $gp_translation_helpers_editor.translation_helper_url + originalId + '?nohc';
     223        const originalId = element.closest( 'tr' ).attr( 'id' ).substring( 7 );
     224        const requestUrl = $gp_translation_helpers_editor.translation_helper_url + originalId + '?nohc';
    225225        $.getJSON( requestUrl, function( data ) {
    226226            $( '[data-tab="sidebar-tab-discussion-' + originalId + '"]' ).html( 'Discussion&nbsp;(' + data[ 'helper-translation-discussion-' + originalId ].count + ')' );
     
    234234    }
    235235
     236    function EventStreamParser( onParse ) {
     237        // npm eventsource-parser
     238        // MIT License
     239        // Copyright (c) 2023 Espen Hovlandsdal <espen@hovlandsdal.com>
     240
     241        // Processing state
     242        let isFirstChunk;
     243        let buffer;
     244        let startingPosition;
     245        let startingFieldLength;
     246
     247        // Event state
     248        let eventId;
     249        let eventName;
     250        let data;
     251
     252        reset();
     253        return { feed, reset };
     254
     255        function reset() {
     256            isFirstChunk = true;
     257            buffer = '';
     258            startingPosition = 0;
     259            startingFieldLength = -1;
     260
     261            eventId = undefined;
     262            eventName = undefined;
     263            data = '';
     264        }
     265
     266        function feed( chunk ) {
     267            buffer = buffer ? buffer + chunk : chunk;
     268
     269            // Strip any UTF8 byte order mark (BOM) at the start of the stream.
     270            // Note that we do not strip any non - UTF8 BOM, as eventsource streams are
     271            // always decoded as UTF8 as per the specification.
     272            if ( isFirstChunk && hasBom( buffer ) ) {
     273                buffer = buffer.slice( 3 );
     274            }
     275
     276            isFirstChunk = false;
     277
     278            // Set up chunk-specific processing state
     279            const length = buffer.length;
     280            let position = 0;
     281            let discardTrailingNewline = false;
     282
     283            // Read the current buffer byte by byte
     284            while ( position < length ) {
     285                // EventSource allows for carriage return + line feed, which means we
     286                // need to ignore a linefeed character if the previous character was a
     287                // carriage return
     288                // @todo refactor to reduce nesting, consider checking previous byte?
     289                // @todo but consider multiple chunks etc
     290                if ( discardTrailingNewline ) {
     291                    if ( buffer[ position ] === '\n' ) {
     292                        ++position;
     293                    }
     294                    discardTrailingNewline = false;
     295                }
     296
     297                let lineLength = -1;
     298                let fieldLength = startingFieldLength;
     299                let character;
     300
     301                for ( let index = startingPosition; lineLength < 0 && index < length; ++index ) {
     302                    character = buffer[ index ];
     303                    if ( character === ':' && fieldLength < 0 ) {
     304                        fieldLength = index - position;
     305                    } else if ( character === '\r' ) {
     306                        discardTrailingNewline = true;
     307                        lineLength = index - position;
     308                    } else if ( character === '\n' ) {
     309                        lineLength = index - position;
     310                    }
     311                }
     312
     313                if ( lineLength < 0 ) {
     314                    startingPosition = length - position;
     315                    startingFieldLength = fieldLength;
     316                    break;
     317                } else {
     318                    startingPosition = 0;
     319                    startingFieldLength = -1;
     320                }
     321
     322                parseEventStreamLine( buffer, position, fieldLength, lineLength );
     323
     324                position += lineLength + 1;
     325            }
     326
     327            if ( position === length ) {
     328                // If we consumed the entire buffer to read the event, reset the buffer
     329                buffer = '';
     330            } else if ( position > 0 ) {
     331                // If there are bytes left to process, set the buffer to the unprocessed
     332                // portion of the buffer only
     333                buffer = buffer.slice( position );
     334            }
     335        }
     336
     337        function parseEventStreamLine(
     338            lineBuffer,
     339            index,
     340            fieldLength,
     341            lineLength
     342        ) {
     343            if ( lineLength === 0 ) {
     344                // We reached the last line of this event
     345                if ( data.length > 0 ) {
     346                    onParse( {
     347                        type: 'event',
     348                        id: eventId,
     349                        event: eventName || undefined,
     350                        data: data.slice( 0, -1 ), // remove trailing newline
     351                    } );
     352
     353                    data = '';
     354                    eventId = undefined;
     355                }
     356                eventName = undefined;
     357                return;
     358            }
     359
     360            const noValue = fieldLength < 0;
     361            const field = lineBuffer.slice( index, index + ( noValue ? lineLength : fieldLength ) );
     362            let step = 0;
     363
     364            if ( noValue ) {
     365                step = lineLength;
     366            } else if ( lineBuffer[ index + fieldLength + 1 ] === ' ' ) {
     367                step = fieldLength + 2;
     368            } else {
     369                step = fieldLength + 1;
     370            }
     371
     372            const position = index + step;
     373            const valueLength = lineLength - step;
     374            const value = lineBuffer.slice( position, position + valueLength ).toString();
     375
     376            if ( field === 'data' ) {
     377                data += value ? `${ value }\n` : '\n';
     378            } else if ( field === 'event' ) {
     379                eventName = value;
     380            } else if ( field === 'id' && ! value.includes( '\u0000' ) ) {
     381                eventId = value;
     382            } else if ( field === 'retry' ) {
     383                const retry = parseInt( value, 10 );
     384                if ( ! Number.isNaN( retry ) ) {
     385                    onParse( { type: 'reconnect-interval', value: retry } );
     386                }
     387            }
     388        }
     389        function hasBom( b ) {
     390            return [ 239, 187, 191 ].every( ( charCode, index ) => b.charCodeAt( index ) === charCode );
     391        }
     392    }
     393
     394    async function invokeChatGPT( prompt, response_span ) {
     395        const request = {
     396            model: 'gpt-3.5-turbo',
     397            messages: prompt,
     398            temperature: parseFloat( $gp_comment_feedback_settings.openai_temperature ),
     399            stream: true,
     400        };
     401        let result = '';
     402        const parser = EventStreamParser( function( event ) {
     403            if ( event.type === 'event' ) {
     404                if ( event.data !== '[DONE]' ) {
     405                    result += JSON.parse( event.data ).choices[ 0 ].delta.content || '';
     406                    response_span.text( result );
     407                }
     408            } else if ( event.type === 'invalid_request_error' ) {
     409                response_span.text( event.value );
     410            } else if ( event.type === 'reconnect-interval' ) {
     411                console.log( 'We should set reconnect interval to %d milliseconds', event.value );
     412            }
     413        } );
     414
     415        let response = await fetch(
     416            'https://api.openai.com/v1/chat/completions',
     417            {
     418                headers: {
     419                    'Content-Type': 'application/json',
     420                    Authorization: `Bearer ${ $gp_comment_feedback_settings.openai_key }`,
     421                },
     422                method: 'POST',
     423                body: JSON.stringify( request ),
     424            }
     425        );
     426
     427        for await (const value of response.body?.pipeThrough(new TextDecoderStream())) {
     428            parser.feed(value)
     429        }
     430    }
     431
    236432    /**
    237433     * Fetch translation review from OpenAI.
     
    239435     * @param {string}  rowId      The row-id attribute of the current row.
    240436     * @param {string}  currentRow The current row.
    241      * @param {boolean} isRetry    The current row.
    242437     */
    243438    function fetchOpenAIReviewResponse( rowId, currentRow, isRetry ) {
    244         var payload = {};
    245         var data = {};
    246         var original_str = currentRow.find( '.original' );
    247         var glossary_prompt = '';
    248         var translation = currentRow.find( '.foreign-text:first' ).val();
     439        const messages = [];
     440        const original_str = currentRow.find( '.original' );
     441        let glossary_prompt = '';
     442        let result = '';
    249443
    250444        $.each( $( original_str ).find( '.glossary-word' ), function( k, word ) {
     
    259453
    260454        if ( '' !== glossary_prompt ) {
    261             glossary_prompt = 'You are required to follow these rules, ' + glossary_prompt + 'for words found in the English text you are translating.';
    262         }
    263         payload.language = $gp_comment_feedback_settings.language;
    264         payload.original = currentRow.find( '.original-raw' ).text();
    265         payload.translation = translation;
    266         payload.glossary_query = glossary_prompt;
    267         payload.is_retry = isRetry;
    268 
    269         data = {
    270             action: 'fetch_openai_review',
    271             data: payload,
    272             _ajax_nonce: $gp_comment_feedback_settings.nonce,
    273         };
    274 
    275         $.ajax(
    276             {
    277                 type: 'POST',
    278                 url: typeof window.useThinFetch !== 'undefined' && window.useThinFetch ? '/wp-content/plugins/wporg-gp-translation-suggestions/ajax-fetch-openai-review.php' : $gp_comment_feedback_settings.url,
    279                 data: data,
    280             }
    281         ).done(
    282             function( response ) {
    283                 currentRow.find( '.openai-review .suggestions__loading-indicator' ).hide();
    284                 if ( response.success ) {
    285                     currentRow.find( '.openai-review .auto-review-result' ).html( '<h4>Review by ChatGPT' ).append( $( '<span/>' ).text( response.data.review + ' (' + response.data.time_taken.toFixed( 2 ) + 's)' ) );
    286                 } else if ( 404 === response.data.status ) {
    287                     currentRow.find( '.openai-review' ).hide();
    288                     return;
    289                 } else {
    290                     currentRow.find( '.openai-review .auto-review-result' ).text( 'Error ' + response.data.status + ' : ' + response.data.error );
    291                 }
    292                 currentRow.find( '.openai-review .auto-review-result' ).append( ' <a href="#" class="retry-auto-review">Retry</a>' );
    293             }
    294         ).fail(
    295             function( xhr, msg ) {
    296                 /* eslint no-console: ["error", { allow: ["error"] }] */
    297                 console.error( data );
    298             }
    299         );
     455            messages.push( {
     456                role: 'system',
     457                content: 'You are required to follow these rules, ' + glossary_prompt + 'for words found in the English text you are translating.',
     458            } );
     459        }
     460        messages.push( {
     461            role: 'system',
     462            content: ( isRetry ? 'Are you sure that ' : '' ) + 'For the english text  "' + currentRow.find( '.original-raw' ).text() + '", is "' + currentRow.find( '.foreign-text:first' ).val() + '" a correct translation in ' + $gp_comment_feedback_settings.language + '? Don\'t repeat the translation if it is correct and point out differences if there are any.',
     463        } );
     464
     465        currentRow.find( '.openai-review .suggestions__loading-indicator' ).hide();
     466        currentRow.find( '.openai-review .auto-review-result' ).html( '<h4>Review by ChatGPT' ).append( '<span style="white-space: pre-line">' );
     467        invokeChatGPT( messages, currentRow.find( '.openai-review .auto-review-result span' ) ).then(()=>currentRow.find( '.openai-review .auto-review-result' ).append( ' <a href="#" class="retry-auto-review">Retry</a>' ));
    300468    }
    301469} );
Note: See TracChangeset for help on using the changeset viewer.