diff --git a/classes/class-rest.php b/classes/class-rest.php index 1ba9a1c..4a1114f 100644 --- a/classes/class-rest.php +++ b/classes/class-rest.php @@ -108,28 +108,12 @@ class Mind_Rest extends WP_REST_Controller { } /** - * Send request to OpenAI. + * Prepare messages for request. * - * @param WP_REST_Request $req request object. - * - * @return mixed + * @param string $request user request. + * @param string $context context. */ - public function request_ai( WP_REST_Request $req ) { - $settings = get_option( 'mind_settings', array() ); - $openai_key = $settings['openai_api_key'] ?? ''; - - $request = $req->get_param( 'request' ) ?? ''; - $context = $req->get_param( 'context' ) ?? ''; - - if ( ! $openai_key ) { - return $this->error( 'no_openai_key_found', __( 'Provide OpenAI key in the plugin settings.', 'mind' ) ); - } - - if ( ! $request ) { - return $this->error( 'no_request', __( 'Provide request to receive AI response.', 'mind' ) ); - } - - // Messages. + public function prepare_messages( $request, $context ) { $messages = []; $messages[] = [ @@ -189,52 +173,75 @@ class Mind_Rest extends WP_REST_Controller { ), ]; + return $messages; + } + + /** + * Send request to OpenAI. + * + * @param WP_REST_Request $req request object. + * + * @return mixed + */ + public function request_ai( WP_REST_Request $req ) { + // Set headers for streaming. + header( 'Content-Type: text/event-stream' ); + header( 'Cache-Control: no-cache' ); + header( 'Connection: keep-alive' ); + // For Nginx. + header( 'X-Accel-Buffering: no' ); + + $settings = get_option( 'mind_settings', array() ); + $openai_key = $settings['openai_api_key'] ?? ''; + + $request = $req->get_param( 'request' ) ?? ''; + $context = $req->get_param( 'context' ) ?? ''; + + if ( ! $openai_key ) { + $this->send_stream_error( 'no_openai_key_found', __( 'Provide OpenAI key in the plugin settings.', 'mind' ) ); + exit; + } + + if ( ! $request ) { + $this->send_stream_error( 'no_request', __( 'Provide request to receive AI response.', 'mind' ) ); + exit; + } + + // Messages. + $messages = $this->prepare_messages( $request, $context ); + $body = [ 'model' => 'gpt-4o-mini', - 'stream' => false, + 'stream' => true, 'temperature' => 0.7, 'messages' => $messages, ]; - // Make Request to OpenAI API. - $ai_request = wp_remote_post( - 'https://api.openai.com/v1/chat/completions', - [ - 'headers' => [ - 'Authorization' => 'Bearer ' . $openai_key, - 'Content-Type' => 'application/json', - ], - 'timeout' => 30, - 'sslverify' => false, - 'body' => wp_json_encode( $body ), - ] - ); + // Initialize cURL. + // phpcs:disable + $ch = curl_init( 'https://api.openai.com/v1/chat/completions' ); + curl_setopt( $ch, CURLOPT_POST, 1 ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $openai_key, + ] ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $body ) ); + curl_setopt( $ch, CURLOPT_WRITEFUNCTION, function ( $curl, $data ) { + $this->process_stream_chunk( $data ); + return strlen( $data ); + }); - // Error. - if ( is_wp_error( $ai_request ) ) { - $response = $ai_request->get_error_message(); + // Execute request + curl_exec( $ch ); - return $this->error( 'openai_request_error', $response ); - } elseif ( wp_remote_retrieve_response_code( $ai_request ) !== 200 ) { - $response = json_decode( wp_remote_retrieve_body( $ai_request ), true ); - - if ( isset( $response['error']['message'] ) ) { - return $this->error( 'openai_request_error', $response['error']['message'] ); - } - - return $this->error( 'openai_request_error', __( 'OpenAI data failed to load.', 'mind' ) ); + if ( curl_errno( $ch ) ) { + $this->send_stream_error( 'curl_error', curl_error( $ch ) ); } - // Success. - $result = ''; - $response = json_decode( wp_remote_retrieve_body( $ai_request ), true ); - - // TODO: this a limited part, which should be reworked. - if ( isset( $response['choices'][0]['message']['content'] ) ) { - $result = $response['choices'][0]['message']['content']; - } - - return $this->success( $result ); + curl_close( $ch ); + // phpcs:enable + exit; } /** @@ -255,6 +262,72 @@ class Mind_Rest extends WP_REST_Controller { return $method . '&' . rawurlencode( $base_uri ) . '&' . rawurlencode( implode( '&', $r ) ); } + /** + * Process streaming chunk from OpenAI + * + * @param string $chunk - chunk of data. + */ + private function process_stream_chunk( $chunk ) { + $lines = explode( "\n", $chunk ); + + foreach ( $lines as $line ) { + if ( strlen( trim( $line ) ) === 0 ) { + continue; + } + + if ( strpos( $line, 'data: ' ) === 0 ) { + $json_data = trim( substr( $line, 6 ) ); + + if ( '[DONE]' === $json_data ) { + $this->send_stream_chunk( [ 'done' => true ] ); + return; + } + + try { + $data = json_decode( $json_data, true ); + + if ( isset( $data['choices'][0]['delta']['content'] ) ) { + // Send smaller chunks immediately. + $this->send_stream_chunk( + [ + 'content' => $data['choices'][0]['delta']['content'], + ] + ); + flush(); + } + } catch ( Exception $e ) { + $this->send_stream_error( 'json_error', $e->getMessage() ); + } + } + } + } + + /** + * Send stream chunk + * + * @param array $data - data to send. + */ + private function send_stream_chunk( $data ) { + echo 'data: ' . wp_json_encode( $data ) . "\n\n"; + flush(); + } + + /** + * Send stream error + * + * @param string $code - error code. + * @param string $message - error message. + */ + private function send_stream_error( $code, $message ) { + $this->send_stream_chunk( + [ + 'error' => true, + 'code' => $code, + 'message' => $message, + ] + ); + } + /** * Success rest. * diff --git a/src/editor/popup/components/ai-response/index.js b/src/editor/popup/components/ai-response/index.js new file mode 100644 index 0000000..d0b0f70 --- /dev/null +++ b/src/editor/popup/components/ai-response/index.js @@ -0,0 +1,70 @@ +/** + * Styles + */ +import './style.scss'; + +/** + * WordPress dependencies + */ +import { useRef, useEffect, RawHTML, memo } from '@wordpress/element'; + +const AIResponse = memo( + function AIResponse({ response, loading }) { + const responseRef = useRef(); + + useEffect(() => { + if (!responseRef.current) { + return; + } + + const popupContent = responseRef.current.closest( + '.mind-popup-content' + ); + + if (!popupContent) { + return; + } + + // Smooth scroll to bottom of response. + const { scrollHeight, clientHeight } = popupContent; + + // Only auto-scroll for shorter contents. + const shouldScroll = scrollHeight - clientHeight < 1000; + + if (shouldScroll) { + popupContent.scrollTo({ + top: scrollHeight, + behavior: 'smooth', + }); + } + }, [response]); + + if (!response && !loading) { + return null; + } + + return ( +
+ {response} + {loading &&
} +
+ ); + }, + (prevProps, nextProps) => { + // Custom memoization to prevent unnecessary rerenders. + return ( + prevProps.renderBuffer.lastUpdate === + nextProps.renderBuffer.lastUpdate && + prevProps.loading === nextProps.loading && + prevProps.progress.isComplete === nextProps.progress.isComplete + ); + } +); + +export default AIResponse; diff --git a/src/editor/popup/components/ai-response/style.scss b/src/editor/popup/components/ai-response/style.scss new file mode 100644 index 0000000..4d23f7d --- /dev/null +++ b/src/editor/popup/components/ai-response/style.scss @@ -0,0 +1,36 @@ +.mind-popup-response { + /* GPU acceleration */ + transform: translateZ(0); + will-change: transform; + + /* Optimize repaints */ + contain: content; + + /* Smooth typing cursor */ + .mind-popup-cursor { + display: inline-block; + width: 1.5px; + height: 1em; + background: currentColor; + margin-left: 2px; + animation: mind-cursor-blink 1s step-end infinite; + } +} + +@keyframes mind-cursor-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +/* Optimize for mobile */ +@media (max-width: 768px) { + .mind-popup-response { + contain: strict; + height: 100%; + } +} diff --git a/src/editor/popup/components/content/index.js b/src/editor/popup/components/content/index.js index bdb32a8..57f910e 100644 --- a/src/editor/popup/components/content/index.js +++ b/src/editor/popup/components/content/index.js @@ -4,15 +4,15 @@ import './style.scss'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useRef, useEffect, RawHTML } from '@wordpress/element'; +import { useRef, useEffect } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { Button } from '@wordpress/components'; /** * Internal dependencies */ -import LoadingText from '../loading-text'; import Notice from '../notice'; +import AIResponse from '../ai-response'; import { ReactComponent as PopupPostTitleAboutIcon } from '../../../../icons/popup-post-title-about.svg'; import { ReactComponent as PopupPostAboutIcon } from '../../../../icons/popup-post-about.svg'; import { ReactComponent as PopupOutlineAboutIcon } from '../../../../icons/popup-outline-about.svg'; @@ -73,29 +73,40 @@ export default function Content() { const { setInput, setScreen } = useDispatch('mind/popup'); - const { isOpen, input, screen, loading, response, error } = useSelect( - (select) => { - const { - isOpen: checkIsOpen, - getInput, - getContext, - getScreen, - getLoading, - getResponse, - getError, - } = select('mind/popup'); + const { + isOpen, + input, + screen, + loading, + response, + progress, + renderBuffer, + error, + } = useSelect((select) => { + const { + isOpen: checkIsOpen, + getInput, + getContext, + getScreen, + getLoading, + getResponse, + getProgress, + getRenderBuffer, + getError, + } = select('mind/popup'); - return { - isOpen: checkIsOpen(), - input: getInput(), - context: getContext(), - screen: getScreen(), - loading: getLoading(), - response: getResponse(), - error: getError(), - }; - } - ); + return { + isOpen: checkIsOpen(), + input: getInput(), + context: getContext(), + screen: getScreen(), + loading: getLoading(), + response: getResponse(), + progress: getProgress(), + renderBuffer: getRenderBuffer(), + error: getError(), + }; + }); function focusInput() { if (ref?.current) { @@ -156,12 +167,14 @@ export default function Content() { {screen === 'request' && (
- {loading && ( - - {__('Waiting for AI response', 'mind')} - + {response && ( + )} - {!loading && response && {response}} {!loading && error && {error}}
)} diff --git a/src/editor/popup/components/footer/index.js b/src/editor/popup/components/footer/index.js index da4b772..e5a123e 100644 --- a/src/editor/popup/components/footer/index.js +++ b/src/editor/popup/components/footer/index.js @@ -7,6 +7,8 @@ import { __ } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; +import LoadingText from '../loading-text'; + export default function Input(props) { const { onInsert } = props; @@ -25,7 +27,7 @@ export default function Input(props) { }; }); - const showFooter = response || (input && !loading && !response); + const showFooter = response || loading || (input && !loading && !response); if (!showFooter) { return null; @@ -33,6 +35,7 @@ export default function Input(props) { return (
+ {loading && {__('Writing', 'mind')}}
{input && !loading && !response && ( )} - {response && ( + {response && !loading && ( <>