Plugin Directory

Changeset 3801388


Ignore:
Timestamp:
11/14/2026 04:05:17 AM (less than one hour ago)
Author:
spacelabapps
Message:

Release 1.1.2

Location:
chat-assist-for-woocommerce
Files:
10 edited
1 copied

Legend:

Unmodified
Added
Removed
  • chat-assist-for-woocommerce/tags/1.1.2/assets/js/frontend-widget.js

    r3800390 r3801388  
    2222        // bounded pre-roll (about 3 s of 16 kHz PCM) and sent in order when listening opens.
    2323        const VOICE_PREROLL_MAX_BYTES = 96 * 1024;
     24        // How long an ended Talk session's socket stays open for the hand-over answer (language switch).
     25        const VOICE_HANDOFF_WAIT_MS = 2500;
    2426        /** Microphone gate: 'closed' while the assistant talks, 'preroll' until the backend listens, then 'open'. */
    2527        function createVoiceCaptureGate( send, maxBytes = VOICE_PREROLL_MAX_BYTES ) {
    … …  
    369371                        this.preloadToken = null;
    370372                        this.preloadWs = null;
     373                        // Talk hand-over to the next session on this page: the handle the backend gave, and a hand-over on its way.
     374                        this.voiceContinuationHandle = null;
     375                        this.voiceHandoff = null;
    371376                        this.preloadWsReady = false;
    372377                        this.preloadReadyPromise = null;
    … …  
    618623                        }
    619624
    620                         document.addEventListener( 'keydown', ( e ) => {
     625// Leaving the page: a Talk hand-over handle that waits for a next start is given back.
     626                        if ( typeof window.addEventListener === 'function' ) {
     627                                window.addEventListener( 'pagehide', ( e ) => {
     628                                        if ( ! e.persisted ) this.revokeVoiceContinuation( true );
     629                                } );
     630                        }
     631
     632                                                document.addEventListener( 'keydown', ( e ) => {
    621633                                if ( e.key === 'Escape' && ! this.panel.classList.contains( 'chafwc-hidden' ) ) {
    622634                                        this.togglePanel();
    … …  
    899911                        if ( this.currentTab === 'voice' ) {
    900912                                if ( this.websocket || this.isConnecting ) {
    901                                         this.stopVoice();
     913                                        // The live call ends, and the next one goes on with the conversation (merchant direction 2026-11-12
     914                                        // and live check 2026-11-14: Talk forgot the product after a language switch; same as Shopify).
     915                                        this.stopVoice( { continues: true } );
    902916                                }
    903917                                this.cleanupPreload();
    … …  
    10471061                        } else {
    10481062                                // Cleanup voice if active
     1063                                this.revokeVoiceContinuation();
    10491064                                if ( this.state === STATE.LISTENING || this.state === STATE.SPEAKING || this.state === STATE.THINKING || this.isConnecting ) {
    10501065                                        this.stopVoice();
    … …  
    10771092                                this.voiceMode.hidden = true;
    10781093                                this.voiceMode.classList.remove( 'chafwc-mode-active' );
     1094                                this.revokeVoiceContinuation();
    10791095                                this.stopVoice();
    10801096                                // Cancel any preload
    … …  
    11751191                        });
    11761192                       
     1193                        // A handle waits for the next Talk start, or is on its way: a session prepared ahead could not use it.
     1194                        if ( this.voiceContinuationHandle || this.voiceHandoff ) {
     1195                                debug( 'Preload skipped - voice continuation pending' );
     1196                                return;
     1197                        }
     1198
    11771199                        // Don't preload if voice not enabled or already connected/preloading
    11781200                        if ( ! hasVoice || this.mode === 'free' || this.websocket || this.isPreloading || this.preloadWs ) {
    … …  
    26172639                                this.startCapturePreroll();
    26182640                               
     2641                                // A session ended to go on is still handing over: wait for its handle. A session prepared ahead
     2642                                // knows nothing of that conversation, so it is dropped and a fresh one starts with the handle.
     2643                                if ( this.voiceHandoff ) await this.voiceHandoff.promise;
     2644                                if ( this.voiceContinuationHandle ) this.cleanupPreload();
     2645
    26192646                                if ( this.preloadWs && this.preloadWs.readyState === WebSocket.OPEN && ! this.preloadWsReady ) {
    26202647                                        await this.waitForPreloadReady( 1500 );
    … …  
    26652692                                debug( 'No preload available, creating new connection...' );
    26662693                                this.cleanupPreload(); // Clear any partial preload
     2694                                this.discardBufferedAudio();
    26672695                               
    26682696                                const tokenResponse = await this.apiRequest( 'session/token', {
    26692697                                        method: 'POST',
    2670                                         body: JSON.stringify({ context: this.getContext() })
     2698                                        body: JSON.stringify( this.voiceTokenRequestBody() )
    26712699                                });
    26722700
    … …  
    27932821                }
    27942822
    2795                 stopVoice() {
     2823                /**
     2824                 * Drops audio that was received outside a reply stream and not played. Such audio (the greeting, late
     2825                 * frames of a reply) belongs to the session it came in: left in the buffer, the tail of the last answer was
     2826                 * played as the next session's greeting and the real greeting never was (merchant QA 2026-11-14, Woo Talk
     2827                 * after a language switch: 0.12 s played of a 2.3 s German greeting). Every end and every fresh start of a
     2828                 * Talk session runs this; a session prepared ahead starts with an empty buffer of its own.
     2829                 */
     2830                discardBufferedAudio() {
     2831                        this.audioChunks = [];
     2832                        this.pendingAudioPlay = false;
     2833                        if ( this.pendingAudioTimeout ) {
     2834                                clearTimeout( this.pendingAudioTimeout );
     2835                                this.pendingAudioTimeout = null;
     2836                        }
     2837                }
     2838
     2839                /** The Talk token request: the page context and, once, the hand-over handle of the session before. */
     2840                voiceTokenRequestBody() {
     2841                        const body = { context: this.getContext() };
     2842                        if ( this.voiceContinuationHandle ) body.continuationHandle = this.voiceContinuationHandle;
     2843                        this.voiceContinuationHandle = null;
     2844                        return body;
     2845                }
     2846
     2847                storeVoiceContinuation( handle ) {
     2848                        if ( typeof handle === 'string' && /^[A-Za-z0-9_-]{43}$/.test( handle ) ) this.voiceContinuationHandle = handle;
     2849                }
     2850
     2851                /** Waits on an ended session's socket for the hand-over answer, keeps the handle and closes the socket. */
     2852                awaitVoiceHandoff( ws ) {
     2853                        const handoff = { cancelled: false, promise: null };
     2854                        handoff.promise = new Promise( resolve => {
     2855                                let done = false;
     2856                                let timer = null;
     2857                                const finish = ( handle = null ) => {
     2858                                        if ( done ) return;
     2859                                        done = true;
     2860                                        clearTimeout( timer );
     2861                                        ws.onmessage = () => {};
     2862                                        ws.onerror = () => {};
     2863                                        ws.onclose = () => {};
     2864                                        try {
     2865                                                ws.close( 1000, 'User ended session' );
     2866                                        } catch ( error ) {
     2867                                                debug( 'Hand-over socket close failed', error );
     2868                                        }
     2869                                        if ( this.voiceHandoff === handoff ) this.voiceHandoff = null;
     2870                                        // Talk was left while the answer was on its way: the handle is given back instead of kept.
     2871                                        if ( handoff.cancelled ) this.sendVoiceContinuationRevoke( handle );
     2872                                        else this.storeVoiceContinuation( handle );
     2873                                        resolve();
     2874                                };
     2875                                timer = setTimeout( () => finish(), VOICE_HANDOFF_WAIT_MS );
     2876                                ws.onmessage = event => {
     2877                                        if ( typeof event.data !== 'string' ) return;
     2878                                        let message = null;
     2879                                        try {
     2880                                                message = JSON.parse( event.data );
     2881                                        } catch ( error ) {
     2882                                                return;
     2883                                        }
     2884                                        if ( message?.type === 'session.continuation' ) finish( message.payload?.continuationHandle ?? null );
     2885                                };
     2886                                ws.onerror = () => finish();
     2887                                ws.onclose = () => finish();
     2888                        });
     2889                        this.voiceHandoff = handoff;
     2890                        // Nothing to carry over: Talk is prepared ahead as after any stop.
     2891                        void handoff.promise.then( () => {
     2892                                if ( ! this.voiceContinuationHandle && ! handoff.cancelled && this.currentTab === 'voice' &&
     2893                                        ! this.panel.classList.contains( 'chafwc-hidden' ) ) this.preloadVoiceConnection();
     2894                        });
     2895                }
     2896
     2897                /** Leaving Talk (panel closed, Chat tab, page left): a handle that will not be used is given back. */
     2898                revokeVoiceContinuation( unloading = false ) {
     2899                        const handle = this.voiceContinuationHandle;
     2900                        this.voiceContinuationHandle = null;
     2901                        if ( this.voiceHandoff ) this.voiceHandoff.cancelled = true;
     2902                        this.sendVoiceContinuationRevoke( handle, unloading );
     2903                }
     2904
     2905                sendVoiceContinuationRevoke( handle, unloading = false ) {
     2906                        if ( typeof handle !== 'string' || ! handle ) return;
     2907                        const body = JSON.stringify({ continuationHandle: handle });
     2908                        if ( unloading && typeof navigator !== 'undefined' && navigator.sendBeacon ) {
     2909                                // A beacon cannot carry the nonce header, so it goes in the address, which the route also reads.
     2910                                const url = `${config.restUrl}session/continuation/revoke?_wpnonce=${encodeURIComponent( config.nonce || '' )}`;
     2911                                if ( navigator.sendBeacon( url, new Blob( [ body ], { type: 'application/json' } ) ) ) return;
     2912                        }
     2913                        void this.apiRequest( 'session/continuation/revoke', { method: 'POST', body } )
     2914                                .then( () => {}, () => debug( 'Voice continuation revoke request failed' ) );
     2915                }
     2916
     2917                /**
     2918                 * Ends Talk. With `continues` (language switch) the next Talk session goes on with this conversation: the
     2919                 * end message asks the backend for a handle, and only the socket stays open, for at most
     2920                 * VOICE_HANDOFF_WAIT_MS, to receive it. The stop button and every other end are as before.
     2921                 */
     2922                stopVoice( { continues = false } = {} ) {
    27962923                        debug( 'stopVoice called' );
    27972924                        this.revealPendingVoiceAssistantTurnText();
    … …  
    28062933                        if ( this.websocket ) {
    28072934                                // Send end message first, then close gracefully
    2808                                 if ( this.websocket.readyState === WebSocket.OPEN ) {
     2935                                const ws = this.websocket;
     2936                                const open = ws.readyState === WebSocket.OPEN;
     2937                                if ( open ) {
    28092938                                        this.sendWsMessage( 'audio.stop', {} );
    2810                                         this.sendWsMessage( 'session.end', { reason: 'user_end' } );
    2811                                 }
     2939                                        this.sendWsMessage( 'session.end', continues ? { reason: 'user_end', continuation: true } : { reason: 'user_end' } );
     2940                                }
     2941                                this.websocket = null;
    28122942                                // Use code 1000 (normal closure) to avoid 1005 error
    2813                                 this.websocket.close( 1000, 'User ended session' );
    2814                                 this.websocket = null;
     2943                                if ( continues && open ) this.awaitVoiceHandoff( ws );
     2944                                else ws.close( 1000, 'User ended session' );
    28152945                        }
    28162946                        this.stopAudioCapture();
    28172947                        this.isConnecting = false;
    2818                         this.pendingAudioPlay = false;
    2819                         if ( this.pendingAudioTimeout ) {
    2820                                 clearTimeout( this.pendingAudioTimeout );
    2821                                 this.pendingAudioTimeout = null;
    2822                         }
     2948                        this.discardBufferedAudio();
    28232949                        this.setState( STATE.IDLE );
    28242950                       
    … …  
    33163442                                        // Backend ended session due to inactivity — clean up and go idle
    33173443                                        debug( 'Session timed out due to inactivity' );
     3444                                        this.storeVoiceContinuation( message.payload?.continuationHandle );
     3445                                        this.discardBufferedAudio();
    33183446                                        this.stopAudioCapture();
    33193447                                        if ( this.websocket ) {
  • chat-assist-for-woocommerce/tags/1.1.2/blocks/chat-assist/block.json

    r3800390 r3801388  
    33        "apiVersion": 3,
    44        "name": "chat-assist/chat-widget",
    5         "version": "1.1.1",
     5        "version": "1.1.2",
    66        "title": "Chat Assist Widget",
    77        "category": "widgets",
  • chat-assist-for-woocommerce/tags/1.1.2/includes/class-rest.php

    r3800390 r3801388  
    166166                                'permission_callback' => array( $this, 'public_permission_check' ),
    167167                                'args'                => array(
    168                                         'context' => array(
     168                                        'context'            => array(
    169169                                                'type'    => 'object',
    170170                                                'default' => array(),
     171                                        ),
     172                                        'continuationHandle' => array(
     173                                                'type'    => 'string',
     174                                                'default' => '',
     175                                        ),
     176                                ),
     177                        )
     178                );
     179
     180                // Give back a Talk hand-over handle that will not be used (panel closed, Chat tab, page left).
     181                register_rest_route(
     182                        $this->namespace,
     183                        '/session/continuation/revoke',
     184                        array(
     185                                'methods'             => 'POST',
     186                                'callback'            => array( $this, 'revoke_voice_continuation' ),
     187                                'permission_callback' => array( $this, 'public_permission_check' ),
     188                                'args'                => array(
     189                                        'continuationHandle' => array(
     190                                                'type'    => 'string',
     191                                                'default' => '',
    171192                                        ),
    172193                                ),
    … …  
    11621183
    11631184                // Request short-lived session token from backend.
    1164                 $response = CHAFWC_Helpers::backend_request(
    1165                         'v1/sessions/token',
    1166                         array(
    1167                                 'method' => 'POST',
    1168                                 'body'   => array(
    1169                                         'origin'      => home_url(),
    1170                                         'userAgent'   => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
    1171                                         'ipHash'      => CHAFWC_Helpers::hash_ip( CHAFWC_Helpers::get_client_ip() ),
    1172                                         'context'     => $context,
    1173                                         'shopContext' => $shop_context,
    1174                                         'greetings'   => $greeting_messages,
    1175                                 ),
    1176                         )
     1185                $response = $this->request_voice_token(
     1186                        array(
     1187                                'origin'      => home_url(),
     1188                                'userAgent'   => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
     1189                                'ipHash'      => CHAFWC_Helpers::hash_ip( CHAFWC_Helpers::get_client_ip() ),
     1190                                'context'     => $context,
     1191                                'shopContext' => $shop_context,
     1192                                'greetings'   => $greeting_messages,
     1193                        ),
     1194                        $this->voice_continuation_handle( $request )
    11771195                );
    11781196
    … …  
    12001218                        )
    12011219                );
     1220        }
     1221
     1222        /**
     1223         * The Talk hand-over handle of a request, or an empty string when it is missing or malformed.
     1224         *
     1225         * @param WP_REST_Request $request Request object.
     1226         * @return string
     1227         */
     1228        public function voice_continuation_handle( $request ) {
     1229                $handle = $request->get_param( 'continuationHandle' );
     1230                return is_string( $handle ) && 1 === preg_match( '/^[A-Za-z0-9_-]{43}$/', $handle ) ? $handle : '';
     1231        }
     1232
     1233        /**
     1234         * Ask the backend for a Talk token. With a hand-over handle the new session goes on with the conversation of
     1235         * the one before it (after a language switch, inactivity or the time limit). A handle the backend no longer
     1236         * accepts must not cost the shopper the call: the token is then requested once more without it.
     1237         *
     1238         * @param array  $body   Token request body.
     1239         * @param string $handle Hand-over handle, or an empty string.
     1240         * @return array|WP_Error
     1241         */
     1242        public function request_voice_token( $body, $handle = '' ) {
     1243                if ( '' !== $handle ) {
     1244                        $response = CHAFWC_Helpers::backend_request(
     1245                                'v1/sessions/token',
     1246                                array(
     1247                                        'method' => 'POST',
     1248                                        'body'   => array_merge( $body, array( 'continuationHandle' => $handle ) ),
     1249                                )
     1250                        );
     1251                        $data = is_wp_error( $response ) ? $response->get_error_data() : null;
     1252                        if ( ! is_array( $data ) || ! isset( $data['status'] ) || 400 !== (int) $data['status'] ) {
     1253                                return $response;
     1254                        }
     1255                }
     1256
     1257                return CHAFWC_Helpers::backend_request(
     1258                        'v1/sessions/token',
     1259                        array(
     1260                                'method' => 'POST',
     1261                                'body'   => $body,
     1262                        )
     1263                );
     1264        }
     1265
     1266        /**
     1267         * Give a Talk hand-over handle back to the backend. Always answers success: the widget has nothing to do
     1268         * with the outcome, and an unknown handle is not an error.
     1269         *
     1270         * @param WP_REST_Request $request Request object.
     1271         * @return WP_REST_Response
     1272         */
     1273        public function revoke_voice_continuation( $request ) {
     1274                $handle = $this->voice_continuation_handle( $request );
     1275                if ( '' !== $handle && CHAFWC_Helpers::has_backend() ) {
     1276                        CHAFWC_Helpers::backend_request(
     1277                                'v1/sessions/continuation/revoke',
     1278                                array(
     1279                                        'method' => 'POST',
     1280                                        'body'   => array( 'continuationHandle' => $handle ),
     1281                                )
     1282                        );
     1283                }
     1284
     1285                return rest_ensure_response( array( 'success' => true ) );
    12021286        }
    12031287
  • chat-assist-for-woocommerce/tags/1.1.2/readme.txt

    r3800390 r3801388  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.1.1
     7Stable tag: 1.1.2
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    115115== Changelog ==
    116116
     117= 1.1.2 =
     118* Talk continues the conversation after a language switch and after the inactivity notice: the assistant still knows the products the shopper asked about.
     119
    117120= 1.1.1 =
    118121* Keep paid chat sessions out of the free fallback: the request limit is now a true per-minute limit, and AI chat streaming counts separately.
    … …  
    158161== Upgrade Notice ==
    159162
     163= 1.1.2 =
     164Talk keeps the conversation after a language switch and after a pause.
     165
    160166= 1.1.1 =
    161167Keeps paid chat sessions from falling back to free mode, fixes Talk audio and first words, adds an AI notice and shows more of the widget in the shopper's language.
  • chat-assist-for-woocommerce/tags/1.1.2/woo-chat-assist.php

    r3800390 r3801388  
    44 * Plugin URI: https://qonvor.com/docs/
    55 * Description: Help shoppers find products, track orders, and get answers in your WooCommerce store. Free chat included, with paid text and voice options.
    6  * Version: 1.1.1
     6 * Version: 1.1.2
    77 * Author: Space Lab Apps Pty Ltd
    88 * Author URI: https://qonvor.com
    … …  
    2222defined( 'ABSPATH' ) || exit;
    2323
    24 define( 'CHAFWC_VERSION', '1.1.1' );
     24define( 'CHAFWC_VERSION', '1.1.2' );
    2525define( 'CHAFWC_PLUGIN_FILE', __FILE__ );
    2626define( 'CHAFWC_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • chat-assist-for-woocommerce/trunk/assets/js/frontend-widget.js

    r3800390 r3801388  
    2222        // bounded pre-roll (about 3 s of 16 kHz PCM) and sent in order when listening opens.
    2323        const VOICE_PREROLL_MAX_BYTES = 96 * 1024;
     24        // How long an ended Talk session's socket stays open for the hand-over answer (language switch).
     25        const VOICE_HANDOFF_WAIT_MS = 2500;
    2426        /** Microphone gate: 'closed' while the assistant talks, 'preroll' until the backend listens, then 'open'. */
    2527        function createVoiceCaptureGate( send, maxBytes = VOICE_PREROLL_MAX_BYTES ) {
    … …  
    369371                        this.preloadToken = null;
    370372                        this.preloadWs = null;
     373                        // Talk hand-over to the next session on this page: the handle the backend gave, and a hand-over on its way.
     374                        this.voiceContinuationHandle = null;
     375                        this.voiceHandoff = null;
    371376                        this.preloadWsReady = false;
    372377                        this.preloadReadyPromise = null;
    … …  
    618623                        }
    619624
    620                         document.addEventListener( 'keydown', ( e ) => {
     625// Leaving the page: a Talk hand-over handle that waits for a next start is given back.
     626                        if ( typeof window.addEventListener === 'function' ) {
     627                                window.addEventListener( 'pagehide', ( e ) => {
     628                                        if ( ! e.persisted ) this.revokeVoiceContinuation( true );
     629                                } );
     630                        }
     631
     632                                                document.addEventListener( 'keydown', ( e ) => {
    621633                                if ( e.key === 'Escape' && ! this.panel.classList.contains( 'chafwc-hidden' ) ) {
    622634                                        this.togglePanel();
    … …  
    899911                        if ( this.currentTab === 'voice' ) {
    900912                                if ( this.websocket || this.isConnecting ) {
    901                                         this.stopVoice();
     913                                        // The live call ends, and the next one goes on with the conversation (merchant direction 2026-11-12
     914                                        // and live check 2026-11-14: Talk forgot the product after a language switch; same as Shopify).
     915                                        this.stopVoice( { continues: true } );
    902916                                }
    903917                                this.cleanupPreload();
    … …  
    10471061                        } else {
    10481062                                // Cleanup voice if active
     1063                                this.revokeVoiceContinuation();
    10491064                                if ( this.state === STATE.LISTENING || this.state === STATE.SPEAKING || this.state === STATE.THINKING || this.isConnecting ) {
    10501065                                        this.stopVoice();
    … …  
    10771092                                this.voiceMode.hidden = true;
    10781093                                this.voiceMode.classList.remove( 'chafwc-mode-active' );
     1094                                this.revokeVoiceContinuation();
    10791095                                this.stopVoice();
    10801096                                // Cancel any preload
    … …  
    11751191                        });
    11761192                       
     1193                        // A handle waits for the next Talk start, or is on its way: a session prepared ahead could not use it.
     1194                        if ( this.voiceContinuationHandle || this.voiceHandoff ) {
     1195                                debug( 'Preload skipped - voice continuation pending' );
     1196                                return;
     1197                        }
     1198
    11771199                        // Don't preload if voice not enabled or already connected/preloading
    11781200                        if ( ! hasVoice || this.mode === 'free' || this.websocket || this.isPreloading || this.preloadWs ) {
    … …  
    26172639                                this.startCapturePreroll();
    26182640                               
     2641                                // A session ended to go on is still handing over: wait for its handle. A session prepared ahead
     2642                                // knows nothing of that conversation, so it is dropped and a fresh one starts with the handle.
     2643                                if ( this.voiceHandoff ) await this.voiceHandoff.promise;
     2644                                if ( this.voiceContinuationHandle ) this.cleanupPreload();
     2645
    26192646                                if ( this.preloadWs && this.preloadWs.readyState === WebSocket.OPEN && ! this.preloadWsReady ) {
    26202647                                        await this.waitForPreloadReady( 1500 );
    … …  
    26652692                                debug( 'No preload available, creating new connection...' );
    26662693                                this.cleanupPreload(); // Clear any partial preload
     2694                                this.discardBufferedAudio();
    26672695                               
    26682696                                const tokenResponse = await this.apiRequest( 'session/token', {
    26692697                                        method: 'POST',
    2670                                         body: JSON.stringify({ context: this.getContext() })
     2698                                        body: JSON.stringify( this.voiceTokenRequestBody() )
    26712699                                });
    26722700
    … …  
    27932821                }
    27942822
    2795                 stopVoice() {
     2823                /**
     2824                 * Drops audio that was received outside a reply stream and not played. Such audio (the greeting, late
     2825                 * frames of a reply) belongs to the session it came in: left in the buffer, the tail of the last answer was
     2826                 * played as the next session's greeting and the real greeting never was (merchant QA 2026-11-14, Woo Talk
     2827                 * after a language switch: 0.12 s played of a 2.3 s German greeting). Every end and every fresh start of a
     2828                 * Talk session runs this; a session prepared ahead starts with an empty buffer of its own.
     2829                 */
     2830                discardBufferedAudio() {
     2831                        this.audioChunks = [];
     2832                        this.pendingAudioPlay = false;
     2833                        if ( this.pendingAudioTimeout ) {
     2834                                clearTimeout( this.pendingAudioTimeout );
     2835                                this.pendingAudioTimeout = null;
     2836                        }
     2837                }
     2838
     2839                /** The Talk token request: the page context and, once, the hand-over handle of the session before. */
     2840                voiceTokenRequestBody() {
     2841                        const body = { context: this.getContext() };
     2842                        if ( this.voiceContinuationHandle ) body.continuationHandle = this.voiceContinuationHandle;
     2843                        this.voiceContinuationHandle = null;
     2844                        return body;
     2845                }
     2846
     2847                storeVoiceContinuation( handle ) {
     2848                        if ( typeof handle === 'string' && /^[A-Za-z0-9_-]{43}$/.test( handle ) ) this.voiceContinuationHandle = handle;
     2849                }
     2850
     2851                /** Waits on an ended session's socket for the hand-over answer, keeps the handle and closes the socket. */
     2852                awaitVoiceHandoff( ws ) {
     2853                        const handoff = { cancelled: false, promise: null };
     2854                        handoff.promise = new Promise( resolve => {
     2855                                let done = false;
     2856                                let timer = null;
     2857                                const finish = ( handle = null ) => {
     2858                                        if ( done ) return;
     2859                                        done = true;
     2860                                        clearTimeout( timer );
     2861                                        ws.onmessage = () => {};
     2862                                        ws.onerror = () => {};
     2863                                        ws.onclose = () => {};
     2864                                        try {
     2865                                                ws.close( 1000, 'User ended session' );
     2866                                        } catch ( error ) {
     2867                                                debug( 'Hand-over socket close failed', error );
     2868                                        }
     2869                                        if ( this.voiceHandoff === handoff ) this.voiceHandoff = null;
     2870                                        // Talk was left while the answer was on its way: the handle is given back instead of kept.
     2871                                        if ( handoff.cancelled ) this.sendVoiceContinuationRevoke( handle );
     2872                                        else this.storeVoiceContinuation( handle );
     2873                                        resolve();
     2874                                };
     2875                                timer = setTimeout( () => finish(), VOICE_HANDOFF_WAIT_MS );
     2876                                ws.onmessage = event => {
     2877                                        if ( typeof event.data !== 'string' ) return;
     2878                                        let message = null;
     2879                                        try {
     2880                                                message = JSON.parse( event.data );
     2881                                        } catch ( error ) {
     2882                                                return;
     2883                                        }
     2884                                        if ( message?.type === 'session.continuation' ) finish( message.payload?.continuationHandle ?? null );
     2885                                };
     2886                                ws.onerror = () => finish();
     2887                                ws.onclose = () => finish();
     2888                        });
     2889                        this.voiceHandoff = handoff;
     2890                        // Nothing to carry over: Talk is prepared ahead as after any stop.
     2891                        void handoff.promise.then( () => {
     2892                                if ( ! this.voiceContinuationHandle && ! handoff.cancelled && this.currentTab === 'voice' &&
     2893                                        ! this.panel.classList.contains( 'chafwc-hidden' ) ) this.preloadVoiceConnection();
     2894                        });
     2895                }
     2896
     2897                /** Leaving Talk (panel closed, Chat tab, page left): a handle that will not be used is given back. */
     2898                revokeVoiceContinuation( unloading = false ) {
     2899                        const handle = this.voiceContinuationHandle;
     2900                        this.voiceContinuationHandle = null;
     2901                        if ( this.voiceHandoff ) this.voiceHandoff.cancelled = true;
     2902                        this.sendVoiceContinuationRevoke( handle, unloading );
     2903                }
     2904
     2905                sendVoiceContinuationRevoke( handle, unloading = false ) {
     2906                        if ( typeof handle !== 'string' || ! handle ) return;
     2907                        const body = JSON.stringify({ continuationHandle: handle });
     2908                        if ( unloading && typeof navigator !== 'undefined' && navigator.sendBeacon ) {
     2909                                // A beacon cannot carry the nonce header, so it goes in the address, which the route also reads.
     2910                                const url = `${config.restUrl}session/continuation/revoke?_wpnonce=${encodeURIComponent( config.nonce || '' )}`;
     2911                                if ( navigator.sendBeacon( url, new Blob( [ body ], { type: 'application/json' } ) ) ) return;
     2912                        }
     2913                        void this.apiRequest( 'session/continuation/revoke', { method: 'POST', body } )
     2914                                .then( () => {}, () => debug( 'Voice continuation revoke request failed' ) );
     2915                }
     2916
     2917                /**
     2918                 * Ends Talk. With `continues` (language switch) the next Talk session goes on with this conversation: the
     2919                 * end message asks the backend for a handle, and only the socket stays open, for at most
     2920                 * VOICE_HANDOFF_WAIT_MS, to receive it. The stop button and every other end are as before.
     2921                 */
     2922                stopVoice( { continues = false } = {} ) {
    27962923                        debug( 'stopVoice called' );
    27972924                        this.revealPendingVoiceAssistantTurnText();
    … …  
    28062933                        if ( this.websocket ) {
    28072934                                // Send end message first, then close gracefully
    2808                                 if ( this.websocket.readyState === WebSocket.OPEN ) {
     2935                                const ws = this.websocket;
     2936                                const open = ws.readyState === WebSocket.OPEN;
     2937                                if ( open ) {
    28092938                                        this.sendWsMessage( 'audio.stop', {} );
    2810                                         this.sendWsMessage( 'session.end', { reason: 'user_end' } );
    2811                                 }
     2939                                        this.sendWsMessage( 'session.end', continues ? { reason: 'user_end', continuation: true } : { reason: 'user_end' } );
     2940                                }
     2941                                this.websocket = null;
    28122942                                // Use code 1000 (normal closure) to avoid 1005 error
    2813                                 this.websocket.close( 1000, 'User ended session' );
    2814                                 this.websocket = null;
     2943                                if ( continues && open ) this.awaitVoiceHandoff( ws );
     2944                                else ws.close( 1000, 'User ended session' );
    28152945                        }
    28162946                        this.stopAudioCapture();
    28172947                        this.isConnecting = false;
    2818                         this.pendingAudioPlay = false;
    2819                         if ( this.pendingAudioTimeout ) {
    2820                                 clearTimeout( this.pendingAudioTimeout );
    2821                                 this.pendingAudioTimeout = null;
    2822                         }
     2948                        this.discardBufferedAudio();
    28232949                        this.setState( STATE.IDLE );
    28242950                       
    … …  
    33163442                                        // Backend ended session due to inactivity — clean up and go idle
    33173443                                        debug( 'Session timed out due to inactivity' );
     3444                                        this.storeVoiceContinuation( message.payload?.continuationHandle );
     3445                                        this.discardBufferedAudio();
    33183446                                        this.stopAudioCapture();
    33193447                                        if ( this.websocket ) {
  • chat-assist-for-woocommerce/trunk/blocks/chat-assist/block.json

    r3800390 r3801388  
    33        "apiVersion": 3,
    44        "name": "chat-assist/chat-widget",
    5         "version": "1.1.1",
     5        "version": "1.1.2",
    66        "title": "Chat Assist Widget",
    77        "category": "widgets",
  • chat-assist-for-woocommerce/trunk/includes/class-rest.php

    r3800390 r3801388  
    166166                                'permission_callback' => array( $this, 'public_permission_check' ),
    167167                                'args'                => array(
    168                                         'context' => array(
     168                                        'context'            => array(
    169169                                                'type'    => 'object',
    170170                                                'default' => array(),
     171                                        ),
     172                                        'continuationHandle' => array(
     173                                                'type'    => 'string',
     174                                                'default' => '',
     175                                        ),
     176                                ),
     177                        )
     178                );
     179
     180                // Give back a Talk hand-over handle that will not be used (panel closed, Chat tab, page left).
     181                register_rest_route(
     182                        $this->namespace,
     183                        '/session/continuation/revoke',
     184                        array(
     185                                'methods'             => 'POST',
     186                                'callback'            => array( $this, 'revoke_voice_continuation' ),
     187                                'permission_callback' => array( $this, 'public_permission_check' ),
     188                                'args'                => array(
     189                                        'continuationHandle' => array(
     190                                                'type'    => 'string',
     191                                                'default' => '',
    171192                                        ),
    172193                                ),
    … …  
    11621183
    11631184                // Request short-lived session token from backend.
    1164                 $response = CHAFWC_Helpers::backend_request(
    1165                         'v1/sessions/token',
    1166                         array(
    1167                                 'method' => 'POST',
    1168                                 'body'   => array(
    1169                                         'origin'      => home_url(),
    1170                                         'userAgent'   => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
    1171                                         'ipHash'      => CHAFWC_Helpers::hash_ip( CHAFWC_Helpers::get_client_ip() ),
    1172                                         'context'     => $context,
    1173                                         'shopContext' => $shop_context,
    1174                                         'greetings'   => $greeting_messages,
    1175                                 ),
    1176                         )
     1185                $response = $this->request_voice_token(
     1186                        array(
     1187                                'origin'      => home_url(),
     1188                                'userAgent'   => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
     1189                                'ipHash'      => CHAFWC_Helpers::hash_ip( CHAFWC_Helpers::get_client_ip() ),
     1190                                'context'     => $context,
     1191                                'shopContext' => $shop_context,
     1192                                'greetings'   => $greeting_messages,
     1193                        ),
     1194                        $this->voice_continuation_handle( $request )
    11771195                );
    11781196
    … …  
    12001218                        )
    12011219                );
     1220        }
     1221
     1222        /**
     1223         * The Talk hand-over handle of a request, or an empty string when it is missing or malformed.
     1224         *
     1225         * @param WP_REST_Request $request Request object.
     1226         * @return string
     1227         */
     1228        public function voice_continuation_handle( $request ) {
     1229                $handle = $request->get_param( 'continuationHandle' );
     1230                return is_string( $handle ) && 1 === preg_match( '/^[A-Za-z0-9_-]{43}$/', $handle ) ? $handle : '';
     1231        }
     1232
     1233        /**
     1234         * Ask the backend for a Talk token. With a hand-over handle the new session goes on with the conversation of
     1235         * the one before it (after a language switch, inactivity or the time limit). A handle the backend no longer
     1236         * accepts must not cost the shopper the call: the token is then requested once more without it.
     1237         *
     1238         * @param array  $body   Token request body.
     1239         * @param string $handle Hand-over handle, or an empty string.
     1240         * @return array|WP_Error
     1241         */
     1242        public function request_voice_token( $body, $handle = '' ) {
     1243                if ( '' !== $handle ) {
     1244                        $response = CHAFWC_Helpers::backend_request(
     1245                                'v1/sessions/token',
     1246                                array(
     1247                                        'method' => 'POST',
     1248                                        'body'   => array_merge( $body, array( 'continuationHandle' => $handle ) ),
     1249                                )
     1250                        );
     1251                        $data = is_wp_error( $response ) ? $response->get_error_data() : null;
     1252                        if ( ! is_array( $data ) || ! isset( $data['status'] ) || 400 !== (int) $data['status'] ) {
     1253                                return $response;
     1254                        }
     1255                }
     1256
     1257                return CHAFWC_Helpers::backend_request(
     1258                        'v1/sessions/token',
     1259                        array(
     1260                                'method' => 'POST',
     1261                                'body'   => $body,
     1262                        )
     1263                );
     1264        }
     1265
     1266        /**
     1267         * Give a Talk hand-over handle back to the backend. Always answers success: the widget has nothing to do
     1268         * with the outcome, and an unknown handle is not an error.
     1269         *
     1270         * @param WP_REST_Request $request Request object.
     1271         * @return WP_REST_Response
     1272         */
     1273        public function revoke_voice_continuation( $request ) {
     1274                $handle = $this->voice_continuation_handle( $request );
     1275                if ( '' !== $handle && CHAFWC_Helpers::has_backend() ) {
     1276                        CHAFWC_Helpers::backend_request(
     1277                                'v1/sessions/continuation/revoke',
     1278                                array(
     1279                                        'method' => 'POST',
     1280                                        'body'   => array( 'continuationHandle' => $handle ),
     1281                                )
     1282                        );
     1283                }
     1284
     1285                return rest_ensure_response( array( 'success' => true ) );
    12021286        }
    12031287
  • chat-assist-for-woocommerce/trunk/readme.txt

    r3800390 r3801388  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.1.1
     7Stable tag: 1.1.2
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    115115== Changelog ==
    116116
     117= 1.1.2 =
     118* Talk continues the conversation after a language switch and after the inactivity notice: the assistant still knows the products the shopper asked about.
     119
    117120= 1.1.1 =
    118121* Keep paid chat sessions out of the free fallback: the request limit is now a true per-minute limit, and AI chat streaming counts separately.
    … …  
    158161== Upgrade Notice ==
    159162
     163= 1.1.2 =
     164Talk keeps the conversation after a language switch and after a pause.
     165
    160166= 1.1.1 =
    161167Keeps paid chat sessions from falling back to free mode, fixes Talk audio and first words, adds an AI notice and shows more of the widget in the shopper's language.
  • chat-assist-for-woocommerce/trunk/woo-chat-assist.php

    r3800390 r3801388  
    44 * Plugin URI: https://qonvor.com/docs/
    55 * Description: Help shoppers find products, track orders, and get answers in your WooCommerce store. Free chat included, with paid text and voice options.
    6  * Version: 1.1.1
     6 * Version: 1.1.2
    77 * Author: Space Lab Apps Pty Ltd
    88 * Author URI: https://qonvor.com
    … …  
    2222defined( 'ABSPATH' ) || exit;
    2323
    24 define( 'CHAFWC_VERSION', '1.1.1' );
     24define( 'CHAFWC_VERSION', '1.1.2' );
    2525define( 'CHAFWC_PLUGIN_FILE', __FILE__ );
    2626define( 'CHAFWC_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.