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 && (
<>