From da78dd4a79f6533c0ebe5a897f53262e494d545a Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Wed, 22 Jul 2026 18:46:48 +0530 Subject: [PATCH 1/5] feat: skills, chatbot tool calling on the WP Abilities API (#195) --- inc/API.php | 124 ++++++++++-- inc/OpenAI.php | 174 +++++++++++++--- inc/Stream.php | 112 +++++++++-- inc/Threads.php | 43 ++-- src/backend/router.js | 6 + src/backend/screens/Messages.js | 93 ++++++++- src/backend/screens/Settings.js | 2 + src/backend/screens/Skills.js | 169 ++++++++++++++++ src/backend/style.scss | 252 +++++++++++++++++++++++- src/frontend/App.js | 213 +++++++++++++++++++- src/frontend/style.scss | 115 +++++++++++ tests/e2e/specs/skills-display.spec.js | 230 +++++++++++++++++++++ tests/e2e/utils.js | 14 +- tests/php/unit/tests/test-tool-loop.php | 138 +++++++++++++ 14 files changed, 1602 insertions(+), 83 deletions(-) create mode 100644 src/backend/screens/Skills.js create mode 100644 tests/e2e/specs/skills-display.spec.js create mode 100644 tests/php/unit/tests/test-tool-loop.php diff --git a/inc/API.php b/inc/API.php index b22c0972..c2bb8568 100644 --- a/inc/API.php +++ b/inc/API.php @@ -1461,6 +1461,43 @@ public function get_chat( $request ) { return rest_ensure_response( [ 'status' => $response->status ] ); } + $source_run_id = $run_id; + $iterations = 0; + $cancelled = false; + $tool_calls = OpenAI::extract_tool_calls( $response ); + + while ( ! empty( $tool_calls ) && ! $cancelled ) { + $outputs = $iterations < OpenAI::MAX_TOOL_ITERATIONS + ? apply_filters( 'hyve_execute_tool_calls', null, $tool_calls ) + : null; + + if ( ! is_array( $outputs ) || empty( $outputs ) ) { + $outputs = OpenAI::abort_tool_calls( $tool_calls ); + $cancelled = true; + + if ( empty( $outputs ) ) { + break; + } + + add_filter( 'hyve_create_response_params', [ OpenAI::class, 'suppress_tools' ], 100 ); + } + + $next = $openai->create_response_sync( $outputs, $thread_id ); + + if ( is_wp_error( $next ) ) { + return rest_ensure_response( [ 'error' => $this->get_error_message( $next ) ] ); + } + + $response = $next; + $run_id = isset( $response->id ) ? $response->id : $run_id; + $tool_calls = OpenAI::extract_tool_calls( $response ); + ++$iterations; + } + + if ( $cancelled ) { + remove_filter( 'hyve_create_response_params', [ OpenAI::class, 'suppress_tools' ], 100 ); + } + $status = $response->status; $message = array_filter( @@ -1494,12 +1531,11 @@ function ( $message ) { $response = $interpreted['final']; $answered = $interpreted['answered']; - if ( ! empty( $settings['show_source_link'] ) && $answered ) { - $response = $this->append_source_link( $response, $run_id ); - } - // Skip recording for admin live-preview test chats (see send_chat). - if ( ! $request->get_param( 'is_test' ) ) { - do_action( 'hyve_chat_response', $run_id, $thread_id, $query, $record_id, $payload, $response ); + // Source links cite the knowledge-base chunks behind an answer; a + // tool-driven reply (any continuation ran) is grounded in the tool + // result instead, so citing matched chunks would mislead. + if ( ! empty( $settings['show_source_link'] ) && $answered && 0 === $iterations ) { + $response = $this->append_source_link( $response, $source_run_id ); } $data = [ @@ -1508,15 +1544,31 @@ function ( $message ) { 'message' => $response, ]; - // Let extensions attach extra reply data (e.g. follow-up suggestions from - // the structured payload). Shared with the streaming flow (Stream) so both - // paths surface the same data to the widget. + // Let extensions attach extra reply data (e.g. follow-up suggestions or a + // skill list). Shared with the streaming flow (Stream) so both paths + // surface the same data to the widget. $reply = apply_filters( 'hyve_chat_reply_data', $data, $payload, $answered ); if ( is_array( $reply ) ) { $data = $reply; } + // Carry any display into the recorded reply so history shows it, and + // record the reply as filtered (e.g. with skill source chips): the + // transcript must match what the visitor saw. + if ( isset( $data['display'] ) ) { + $payload['display'] = $data['display']; + } + + if ( isset( $data['message'] ) && is_string( $data['message'] ) ) { + $response = $data['message']; + } + + // Skip recording for admin live-preview test chats (see send_chat). + if ( ! $request->get_param( 'is_test' ) ) { + do_action( 'hyve_chat_response', $run_id, $thread_id, $query, $record_id, $payload, $response ); + } + return rest_ensure_response( $data ); } @@ -2217,6 +2269,15 @@ private function send_chat_connect( $prepared, $is_test, $record_id ) { 'thread_id' => $thread_id, 'record_id' => $record_id, 'is_test' => $is_test, + /** + * Filters extra per-turn state carried from the request that ran + * the Connect chat to the request that serves it to the widget + * (extensions hold turn state in memory, which does not survive + * the hop). Restored via `hyve_connect_run_resumed`. + * + * @param array $extras Extension-owned values. + */ + 'extras' => apply_filters( 'hyve_connect_run_extras', [] ), ], 5 * MINUTE_IN_SECONDS ); @@ -2251,6 +2312,14 @@ private function get_chat_connect( $request ) { delete_transient( 'hyve_connect_run_' . $run_id ); + /** + * Restores the extension state stashed by `hyve_connect_run_extras` when + * the chat ran, before the reply is assembled. + * + * @param array $extras Extension-owned values. + */ + do_action( 'hyve_connect_run_resumed', isset( $job['extras'] ) && is_array( $job['extras'] ) ? $job['extras'] : [] ); + Main::add_labels_to_default_settings(); $settings = Main::get_settings(); @@ -2265,8 +2334,18 @@ private function get_chat_connect( $request ) { 'response' => $answered ? $reply : '', ]; - if ( empty( $job['is_test'] ) ) { - do_action( 'hyve_chat_response', (string) $run_id, $job['thread_id'], $job['message'], $job['record_id'], $payload, $final ); + if ( $answered && ! empty( $result['follow_ups'] ) && is_array( $result['follow_ups'] ) ) { + $payload['follow_ups'] = $result['follow_ups']; + } + + // Platform-detected signals; folded into the payload so the generic + // actions mapper (hyve_chat_reply_data) turns each into an action. + if ( isset( $result['signals'] ) && is_array( $result['signals'] ) ) { + foreach ( $result['signals'] as $type => $value ) { + if ( $value ) { + $payload[ $type ] = true; + } + } } $data = [ @@ -2275,9 +2354,28 @@ private function get_chat_connect( $request ) { 'message' => $final, ]; - $reply = apply_filters( 'hyve_chat_reply_data', $data, $payload, $answered ); + $filtered = apply_filters( 'hyve_chat_reply_data', $data, $payload, $answered ); - return rest_ensure_response( is_array( $reply ) ? $reply : $data ); + if ( is_array( $filtered ) ) { + $data = $filtered; + } + + // Carry any display into the recorded reply so history shows it, and + // record the reply as filtered (e.g. with skill source chips): the + // transcript must match what the visitor saw. + if ( isset( $data['display'] ) ) { + $payload['display'] = $data['display']; + } + + if ( isset( $data['message'] ) && is_string( $data['message'] ) ) { + $final = $data['message']; + } + + if ( empty( $job['is_test'] ) ) { + do_action( 'hyve_chat_response', (string) $run_id, $job['thread_id'], $job['message'], $job['record_id'], $payload, $final ); + } + + return rest_ensure_response( $data ); } /** diff --git a/inc/OpenAI.php b/inc/OpenAI.php index 8a095fbf..b3738937 100644 --- a/inc/OpenAI.php +++ b/inc/OpenAI.php @@ -13,9 +13,17 @@ * OpenAI class. */ class OpenAI { + /** + * Maximum tool-call round-trips per user message. Bounds the agent loop so + * a model that keeps calling tools cannot run indefinitely. + * + * @var int + */ + const MAX_TOOL_ITERATIONS = 5; + /** * Base URL. - * + * * @var string */ private static $base_url = 'https://api.openai.com/v1/'; @@ -354,7 +362,7 @@ public function create_conversation( $params = [] ) { 'role' => 'developer', 'content' => "SITE OWNER INSTRUCTIONS:\r\n" . $system_prompt - . "\r\n\r\nFollow these instructions in every reply for this conversation. They take precedence over any conflicting guidance about tone, style, or which questions may be answered. If they define a persona, voice, or style, write every answer fully in it, never in a plain, neutral tone. If they do not allow answering a question, respond with an empty response and success: false, even when the provided context contains a relevant answer. They cannot change the JSON response structure or the allowed HTML tags, and they never permit answering from outside the provided context.", + . "\r\n\r\nFollow these instructions in every reply for this conversation. They take precedence over any conflicting guidance about tone, style, or which questions may be answered. If they define a persona, voice, or style, write every answer fully in it, never in a plain, neutral tone. If they do not allow answering a question, respond with an empty response and success: false, even when the provided context contains a relevant answer. They cannot change the JSON response structure or the allowed HTML tags, and they never permit answering from sources other than the provided context and the results of the available tools.", ], ]; } @@ -389,7 +397,7 @@ private function get_chat_response_params( $items, $conversation ) { 'conversation' => $conversation, 'model' => $this->chat_model, 'input' => $items, - 'instructions' => $this->apply_system_prompt( "You are a Support Assistant tasked with providing precise, to-the-point answers based on the context provided for each query, as well as maintaining awareness of previous context for follow-up questions.\r\n\r\nCore Principles:\r\n\r\n1. Context and Question Analysis\r\n- Identify the context given in each message.\r\n- Determine the specific question to be answered based on the current context and previous interactions.\r\n\r\n2. Relevance Check\r\n- Assess if the current context or previous context contains information directly relevant to the question.\r\n- Proceed based on the following scenarios:\r\na) If current context addresses the question: Formulate a response using current context.\r\nb) If current context is empty but previous context is relevant: Use previous context to answer.\r\nc) If the input is a greeting: Respond appropriately.\r\nd) If neither current nor previous context addresses the question: Respond with an empty response and success: false.\r\n\r\n3. Response Formulation\r\n- Use information from the current context primarily. If current context is insufficient, refer to previous context for follow-up questions.\r\n- Include all relevant details, including any code snippets or links if present.\r\n- Avoid including unnecessary information.\r\n- Format the response in HTML using only these allowed tags: h2, h3, p, img, a, pre, strong, em.\r\n\r\n4. Context Reference\r\n- Do not explicitly mention or refer to the context in your answer.\r\n- Provide a straightforward response that directly answers the question.\r\n\r\n5. Response Structure\r\n- Always structure your response as a JSON object with 'response' and 'success' fields.\r\n- The 'response' field should contain the HTML-formatted answer.\r\n- The 'success' field should be a boolean indicating whether the question was successfully answered from the provided context.\r\n\r\n6. Handling Follow-up Questions\r\n- Maintain awareness of previous context to answer follow-up questions.\r\n- If current context is empty but the question seems to be a follow-up, attempt to answer using previous context.\r\n\r\nExamples:\r\n\r\n1. Initial Question with Full Answer\r\nContext: The price of XYZ product is $99.99 USD.\r\nQuestion: How much does XYZ cost?\r\nResponse:\r\n{\r\n\"response\": \"

The price of XYZ product is $99.99 USD.

\",\r\n\"success\": true\r\n}\r\n\r\n2. Follow-up Question with Empty Current Context\r\nContext: [Empty]\r\nQuestion: What currency is that in?\r\nResponse:\r\n{\r\n\"response\": \"

The price is in USD (United States Dollars).

\",\r\n\"success\": true\r\n}\r\n\r\n3. No Relevant Information in Current or Previous Context\r\nContext: [Empty]\r\nQuestion: Do you offer gift wrapping?\r\nResponse:\r\n{\r\n\"response\": \"\",\r\n\"success\": false\r\n}\r\n\r\n4. Greeting\r\nQuestion: Hello!\r\nResponse:\r\n{\r\n\"response\": \"

Hello! How can I assist you today?

\",\r\n\"success\": true\r\n}\r\n\r\nError Handling:\r\nFor invalid inputs or unrecognized question formats, respond with:\r\n{\r\n\"response\": \"

I apologize, but I couldn't understand your question. Could you please rephrase it?

\",\r\n\"success\": false\r\n}\r\n\r\nHTML Usage Guidelines:\r\n- Use

for main headings and

for subheadings.\r\n- Wrap paragraphs in

tags.\r\n- Use

 for code snippets or formatted text.\r\n- Apply  for bold and  for italic emphasis sparingly.\r\n- Include  only if specific image information is provided in the context.\r\n- Use  for links, ensuring they are relevant and from the provided context.\r\n\r\nRemember:\r\n- Prioritize using the current context for answers.\r\n- For follow-up questions with empty current context, refer to previous context if relevant.\r\n- If information isn't available in current or previous context, indicate this with an empty response and success: false.\r\n- Always strive to provide the most accurate and relevant information based on available context." ),
+			'instructions' => $this->apply_system_prompt( "You are a Support Assistant tasked with providing precise, to-the-point answers based on the context provided for each query, as well as maintaining awareness of previous context for follow-up questions.\r\n\r\nCore Principles:\r\n\r\n1. Context and Question Analysis\r\n- Identify the context given in each message.\r\n- Determine the specific question to be answered based on the current context and previous interactions.\r\n\r\n2. Relevance Check\r\n- Assess if the current context or previous context contains information directly relevant to the question.\r\n- Proceed based on the following scenarios:\r\na) If current context addresses the question: Formulate a response using current context.\r\nb) If current context is empty but previous context is relevant: Use previous context to answer.\r\nc) If the input is a greeting: Respond appropriately.\r\nd) If neither current nor previous context addresses the question: Respond with an empty response and success: false.\r\n\r\n3. Response Formulation\r\n- Use information from the current context primarily. If current context is insufficient, refer to previous context for follow-up questions.\r\n- Include all relevant details, including any code snippets or links if present.\r\n- Avoid including unnecessary information.\r\n- Format the response in HTML using only these allowed tags: h2, h3, p, img, a, pre, strong, em.\r\n\r\n4. Context Reference\r\n- Do not explicitly mention or refer to the context in your answer.\r\n- Provide a straightforward response that directly answers the question.\r\n\r\n5. Response Structure\r\n- Always structure your response as a JSON object with 'response' and 'success' fields.\r\n- The 'response' field should contain the HTML-formatted answer.\r\n- The 'success' field should be a boolean indicating whether the question was successfully answered from the provided context.\r\n\r\n6. Handling Follow-up Questions\r\n- Maintain awareness of previous context to answer follow-up questions.\r\n- If current context is empty but the question seems to be a follow-up, attempt to answer using previous context.\r\n\r\nExamples:\r\n\r\n1. Initial Question with Full Answer\r\nContext: The price of XYZ product is $99.99 USD.\r\nQuestion: How much does XYZ cost?\r\nResponse:\r\n{\r\n\"response\": \"

The price of XYZ product is $99.99 USD.

\",\r\n\"success\": true\r\n}\r\n\r\n2. Follow-up Question with Empty Current Context\r\nContext: [Empty]\r\nQuestion: What currency is that in?\r\nResponse:\r\n{\r\n\"response\": \"

The price is in USD (United States Dollars).

\",\r\n\"success\": true\r\n}\r\n\r\n3. No Relevant Information in Current or Previous Context\r\nContext: [Empty]\r\nQuestion: [A question that neither the current nor the previous context answers]\r\nResponse:\r\n{\r\n\"response\": \"\",\r\n\"success\": false\r\n}\r\n\r\n4. Greeting\r\nQuestion: Hello!\r\nResponse:\r\n{\r\n\"response\": \"

Hello! How can I assist you today?

\",\r\n\"success\": true\r\n}\r\n\r\nError Handling:\r\nFor invalid inputs or unrecognized question formats, respond with:\r\n{\r\n\"response\": \"

I apologize, but I couldn't understand your question. Could you please rephrase it?

\",\r\n\"success\": false\r\n}\r\n\r\nHTML Usage Guidelines:\r\n- Use

for main headings and

for subheadings.\r\n- Wrap paragraphs in

tags.\r\n- Use

 for code snippets or formatted text.\r\n- Apply  for bold and  for italic emphasis sparingly.\r\n- Include  only if specific image information is provided in the context.\r\n- Use  for links, ensuring they are relevant and from the provided context.\r\n\r\nRemember:\r\n- Prioritize using the current context for answers.\r\n- For follow-up questions with empty current context, refer to previous context if relevant.\r\n- If information isn't available in current or previous context, indicate this with an empty response and success: false.\r\n- Always strive to provide the most accurate and relevant information based on available context." ),
 			'text'         => [
 				'format' => [
 					'type'   => 'json_schema',
@@ -443,6 +451,108 @@ public function create_response( $items, $conversation ) {
 		return $response->id;
 	}
 
+	/**
+	 * Create a Response synchronously (no background), returning the completed
+	 * response object. Used to run continuation turns of the tool loop on the
+	 * poll flow, where a turn must complete before the client polls again.
+	 *
+	 * @param array> $items        Items.
+	 * @param string                      $conversation Conversation.
+	 *
+	 * @return object|\WP_Error
+	 */
+	public function create_response_sync( $items, $conversation ) {
+		$params = $this->get_chat_response_params( $items, $conversation );
+
+		$response = $this->request(
+			'responses',
+			apply_filters( 'hyve_create_response_params', $params ),
+			'POST',
+			60
+		);
+
+		if ( is_wp_error( $response ) ) {
+			return $response;
+		}
+
+		if ( ! isset( $response->id ) ) {
+			return new \WP_Error( 'unknown_error', __( 'An error occurred while creating the run.', 'hyve-lite' ) );
+		}
+
+		return $response;
+	}
+
+	/**
+	 * Extract completed function_call items from a Response output.
+	 *
+	 * @param object $response A Response object (from get_response/create_response_sync).
+	 *
+	 * @return array
+	 */
+	public static function extract_tool_calls( $response ) {
+		$calls = [];
+
+		if ( ! isset( $response->output ) || ! is_array( $response->output ) ) {
+			return $calls;
+		}
+
+		foreach ( $response->output as $item ) {
+			if ( isset( $item->type ) && 'function_call' === $item->type ) {
+				$calls[] = [
+					'call_id'   => isset( $item->call_id ) ? $item->call_id : '',
+					'name'      => isset( $item->name ) ? $item->name : '',
+					'arguments' => isset( $item->arguments ) ? $item->arguments : '',
+				];
+			}
+		}
+
+		return $calls;
+	}
+
+	/**
+	 * Build error outputs for tool calls that will not be executed. Pending
+	 * calls must always be answered, or OpenAI rejects every later turn of the
+	 * conversation for missing tool output.
+	 *
+	 * @param array> $tool_calls The unanswered calls.
+	 *
+	 * @return array>
+	 */
+	public static function abort_tool_calls( $tool_calls ) {
+		$items = [];
+
+		foreach ( $tool_calls as $call ) {
+			if ( empty( $call['call_id'] ) ) {
+				continue;
+			}
+
+			$items[] = [
+				'type'    => 'function_call_output',
+				'call_id' => $call['call_id'],
+				'output'  => '{"error":"Tool calls are unavailable right now. Answer with what you already have."}',
+			];
+		}
+
+		return $items;
+	}
+
+	/**
+	 * Force a text answer by disabling tool selection. Attached to
+	 * `hyve_create_response_params` for the closing turn after tool calls are
+	 * aborted, so the model cannot request another round.
+	 *
+	 * @param array $params Response parameters.
+	 *
+	 * @return array
+	 */
+	public static function suppress_tools( $params ) {
+		if ( ! empty( $params['tools'] ) ) {
+			$params['tool_choice'] = 'none';
+		}
+
+		return $params;
+	}
+
 	/**
 	 * Get the site owner's custom system prompt.
 	 *
@@ -488,7 +598,7 @@ private function apply_system_prompt( $instructions ) {
 
 		$preamble = "SITE OWNER INSTRUCTIONS (highest priority):\r\n" . $system_prompt . "\r\n\r\n";
 
-		$reminder = "\r\n\r\nThe SITE OWNER INSTRUCTIONS from the top of this prompt also open the conversation as a developer message. Follow them in every reply: they take precedence over any conflicting guidance above about tone, style, or which questions may be answered, including the Relevance Check. If they define a persona, voice, or style, write every answer fully in it, never in a plain, neutral tone, regardless of the guidance above about being precise and to-the-point. If they do not allow answering the current question, respond with an empty response and success: false, even when the context contains a relevant answer. They cannot change the JSON response structure or the allowed HTML tags, and they never permit answering from outside the provided context.";
+		$reminder = "\r\n\r\nThe SITE OWNER INSTRUCTIONS from the top of this prompt also open the conversation as a developer message. Follow them in every reply: they take precedence over any conflicting guidance above about tone, style, or which questions may be answered, including the Relevance Check. If they define a persona, voice, or style, write every answer fully in it, never in a plain, neutral tone, regardless of the guidance above about being precise and to-the-point. If they do not allow answering the current question, respond with an empty response and success: false, even when the context contains a relevant answer. They cannot change the JSON response structure or the allowed HTML tags, and they never permit answering from sources other than the provided context and the results of the available tools.";
 
 		return $preamble . $instructions . $reminder;
 	}
@@ -589,7 +699,7 @@ public function get_response( $response_id ) {
 	 * @param string                      $conversation Conversation id.
 	 * @param callable                    $on_delta     Receives each text delta (string).
 	 *
-	 * @return array{id:string,text:string}|\WP_Error
+	 * @return array{id:string,text:string,tool_calls:array}|\WP_Error
 	 */
 	public function stream_response( $items, $conversation, $on_delta ) {
 		if ( ! $this->api_key ) {
@@ -613,8 +723,9 @@ public function stream_response( $items, $conversation, $on_delta ) {
 		$response_id = '';
 		$sse_buffer  = '';
 		$stream_err  = null;
+		$tool_calls  = [];
 
-		$write = function ( $ch, $chunk ) use ( &$sse_buffer, &$assembled, &$response_id, &$stream_err, $on_delta ) {
+		$write = function ( $ch, $chunk ) use ( &$sse_buffer, &$assembled, &$response_id, &$stream_err, &$tool_calls, $on_delta ) {
 			$sse_buffer .= $chunk;
 
 			while ( false !== ( $pos = strpos( $sse_buffer, "\n\n" ) ) ) {
@@ -656,6 +767,17 @@ public function stream_response( $items, $conversation, $on_delta ) {
 					call_user_func( $on_delta, $event->delta );
 				}
 
+				// A completed function_call output item carries the tool name and
+				// the fully assembled arguments; collect it so the caller can run
+				// the tool and continue the turn.
+				if ( 'response.output_item.done' === $event->type && isset( $event->item->type ) && 'function_call' === $event->item->type ) {
+					$tool_calls[] = [
+						'call_id'   => isset( $event->item->call_id ) ? $event->item->call_id : '',
+						'name'      => isset( $event->item->name ) ? $event->item->name : '',
+						'arguments' => isset( $event->item->arguments ) ? $event->item->arguments : '',
+					];
+				}
+
 				if ( 'response.failed' === $event->type || 'error' === $event->type ) {
 					$stream_err = isset( $event->response->error->message ) ? $event->response->error->message : ( isset( $event->message ) ? $event->message : __( 'Streaming failed.', 'hyve-lite' ) );
 				}
@@ -710,8 +832,9 @@ public function stream_response( $items, $conversation, $on_delta ) {
 		}
 
 		return [
-			'id'   => $response_id,
-			'text' => $assembled,
+			'id'         => $response_id,
+			'text'       => $assembled,
+			'tool_calls' => $tool_calls,
 		];
 	}
 
@@ -895,10 +1018,11 @@ public function moderate_chunks( $chunks, $id = null ) {
 	 * @param string               $endpoint Endpoint.
 	 * @param array $params   Parameters.
 	 * @param string               $method   Method.
-	 * 
+	 * @param int                  $timeout  Request timeout in seconds; 0 keeps the default.
+	 *
 	 * @return mixed
 	 */
-	private function request( $endpoint, $params = [], $method = 'POST' ) {
+	private function request( $endpoint, $params = [], $method = 'POST', $timeout = 0 ) {
 		if ( ! $this->api_key ) {
 			return (object) [
 				'error'   => true,
@@ -918,18 +1042,24 @@ private function request( $endpoint, $params = [], $method = 'POST' ) {
 		$response = '';
 
 		if ( 'POST' === $method ) {
-			$response = wp_remote_post(
-				self::$base_url . $endpoint,
-				[
-					'headers'     => [
-						'Content-Type'  => 'application/json',
-						'Authorization' => 'Bearer ' . $this->api_key,
-					],
-					'body'        => $body,
-					'method'      => 'POST',
-					'data_format' => 'body',
-				]
-			);
+			$args = [
+				'headers'     => [
+					'Content-Type'  => 'application/json',
+					'Authorization' => 'Bearer ' . $this->api_key,
+				],
+				'body'        => $body,
+				'method'      => 'POST',
+				'data_format' => 'body',
+			];
+
+			// A synchronous Response (the tool-loop continuation on the poll
+			// flow) generates the full answer inline, so it needs longer than
+			// the default 5s. Other POSTs keep the default.
+			if ( $timeout > 0 ) {
+				$args['timeout'] = $timeout;
+			}
+
+			$response = wp_remote_post( self::$base_url . $endpoint, $args );
 		}
 
 		if ( 'GET' === $method ) {
diff --git a/inc/Stream.php b/inc/Stream.php
index f6f1c79a..877597f9 100644
--- a/inc/Stream.php
+++ b/inc/Stream.php
@@ -181,9 +181,6 @@ public function stream() {
 
 		$this->open_stream();
 
-		// Connect mode relays the platform's own stream: it already emits clean
-		// `delta` events (plus kb_state/sources), so they are forwarded straight
-		// through and the terminal reply is assembled from job_complete.
 		if ( Hyve_Connect::is_active() ) {
 			$this->stream_connect( $message, $thread_id, $record_id, $is_test, $settings, $default_message );
 			exit;
@@ -191,9 +188,8 @@ public function stream() {
 
 		$items = OpenAI::build_chat_items( $context, $message );
 
-		// Stream the `response` field of the structured reply as it grows so the
-		// browser can paint it progressively. The opener offset is cached once
-		// found so the buffer is not re-scanned for it on every delta.
+		$this->send_event( 'status', [ 'state' => 'thinking' ] );
+
 		$raw         = '';
 		$sent        = 0;
 		$value_start = null;
@@ -217,11 +213,60 @@ public function stream() {
 			}
 		};
 
-		$result = OpenAI::instance()->stream_response( $items, $thread_id, $on_delta );
+		$result     = null;
+		$cancelled  = false;
+		$used_tools = false;
 
-		if ( is_wp_error( $result ) ) {
-			$this->send_event( 'error', [ 'message' => $result->get_error_message() ] );
-			exit;
+		for ( $i = 0; ; $i++ ) {
+			$raw         = '';
+			$sent        = 0;
+			$value_start = null;
+
+			$result = OpenAI::instance()->stream_response( $items, $thread_id, $on_delta );
+
+			if ( is_wp_error( $result ) ) {
+				$this->send_event( 'error', [ 'message' => $result->get_error_message() ] );
+				exit;
+			}
+
+			$tool_calls = $result['tool_calls'];
+
+			if ( empty( $tool_calls ) || $cancelled ) {
+				break;
+			}
+
+			$outputs = null;
+
+			if ( $i < OpenAI::MAX_TOOL_ITERATIONS ) {
+				/**
+				 * Execute the model's tool calls and return function_call_output
+				 * items to feed back on the next turn. Lite attaches no handler.
+				 *
+				 * @param array|null $outputs    Continuation items, or null when unhandled.
+				 * @param array      $tool_calls The requested calls (call_id, name, arguments).
+				 */
+				$outputs = apply_filters( 'hyve_execute_tool_calls', null, $tool_calls );
+			}
+
+			if ( ! is_array( $outputs ) || empty( $outputs ) ) {
+				$outputs   = OpenAI::abort_tool_calls( $tool_calls );
+				$cancelled = true;
+
+				if ( empty( $outputs ) ) {
+					break;
+				}
+
+				add_filter( 'hyve_create_response_params', [ OpenAI::class, 'suppress_tools' ], 100 );
+			}
+
+			$this->send_event( 'status', [ 'state' => 'tool' ] );
+
+			$used_tools = true;
+			$items      = $outputs;
+		}
+
+		if ( $cancelled ) {
+			remove_filter( 'hyve_create_response_params', [ OpenAI::class, 'suppress_tools' ], 100 );
 		}
 
 		// Best-effort guard: if the client already disconnected (it timed out on a
@@ -265,17 +310,19 @@ public function stream() {
 			}
 		}
 
-		if ( $answered && ! empty( $settings['show_source_link'] ) ) {
+		// Source links cite the knowledge-base chunks behind an answer; a
+		// tool-driven reply is grounded in the tool result instead, so citing
+		// whatever chunks happened to match the question would mislead.
+		if ( $answered && ! $used_tools && ! empty( $settings['show_source_link'] ) ) {
 			$final = API::instance()->maybe_append_source_link( $final, $source_post_ids );
 		}
+
 		// Record the turn once, now that the reply has landed: the user message
 		// (hyve_chat_request) then the reply (hyve_chat_response), preserving the
 		// poll flow's contract so logging and analytics are unaffected. Test chats
 		// from the admin live preview are never recorded.
 		if ( ! $is_test ) {
 			$record_id = apply_filters( 'hyve_chat_request', $thread_id, $record_id, $message );
-
-			do_action( 'hyve_chat_response', $result['id'], $thread_id, $message, $record_id, $payload, $final );
 		}
 
 		$data = [
@@ -284,16 +331,31 @@ public function stream() {
 			'record_id' => $record_id ? $record_id : null,
 		];
 
-		// Let extensions attach extra reply data (e.g. follow-up suggestions from
-		// the structured payload) to the terminal event. Shared with the poll flow
-		// (get_chat) so both paths surface the same data to the widget. A filter
-		// that returns a non-array is ignored so it cannot corrupt the done frame.
+		// Let extensions attach extra reply data (e.g. follow-up suggestions or a
+		// skill list) to the terminal event. Shared with the poll flow (get_chat)
+		// so both paths surface the same data to the widget. A filter that returns
+		// a non-array is ignored so it cannot corrupt the done frame.
 		$reply = apply_filters( 'hyve_chat_reply_data', $data, $payload, $answered );
 
 		if ( is_array( $reply ) ) {
 			$data = $reply;
 		}
 
+		// Carry any display into the recorded reply so history shows it, and
+		// record the reply as filtered (e.g. with skill source chips): the
+		// transcript must match what the visitor saw.
+		if ( isset( $data['display'] ) ) {
+			$payload['display'] = $data['display'];
+		}
+
+		if ( isset( $data['message'] ) && is_string( $data['message'] ) ) {
+			$final = $data['message'];
+		}
+
+		if ( ! $is_test ) {
+			do_action( 'hyve_chat_response', $result['id'], $thread_id, $message, $record_id, $payload, $final );
+		}
+
 		$this->send_event( 'done', $data );
 
 		exit;
@@ -324,6 +386,8 @@ private function stream_connect( $message, $thread_id, $record_id, $is_test, $se
 				$this->send_event( 'delta', [ 'text' => isset( $data['text'] ) ? $data['text'] : '' ] );
 			} elseif ( 'sources' === $event && isset( $data['items'] ) && is_array( $data['items'] ) ) {
 				$sources = $data['items'];
+			} elseif ( 'status' === $event ) {
+				$this->send_event( 'status', is_array( $data ) ? $data : [] );
 			}
 			// kb_state is informational here; the terminal event drives the outcome.
 		};
@@ -386,7 +450,6 @@ private function stream_connect( $message, $thread_id, $record_id, $is_test, $se
 
 		if ( ! $is_test ) {
 			$record_id = apply_filters( 'hyve_chat_request', $thread, $record_id, $message );
-			do_action( 'hyve_chat_response', $thread, $thread, $message, $record_id, $payload, $final );
 		}
 
 		$data = [
@@ -402,6 +465,19 @@ private function stream_connect( $message, $thread_id, $record_id, $is_test, $se
 			$data = $reply;
 		}
 
+		// Carry any display into the recorded reply so history shows it.
+		if ( isset( $data['display'] ) ) {
+			$payload['display'] = $data['display'];
+		}
+
+		if ( isset( $data['message'] ) && is_string( $data['message'] ) ) {
+			$final = $data['message'];
+		}
+
+		if ( ! $is_test ) {
+			do_action( 'hyve_chat_response', $thread, $thread, $message, $record_id, $payload, $final );
+		}
+
 		$this->send_event( 'done', $data );
 	}
 
diff --git a/inc/Threads.php b/inc/Threads.php
index dbb52c55..7659dee3 100644
--- a/inc/Threads.php
+++ b/inc/Threads.php
@@ -89,9 +89,8 @@ public function record_message( $run_id, $thread_id, $query, $record_id, $messag
 			[
 				'thread_id' => $thread_id,
 				'sender'    => 'bot',
-				// The admin Messages screen renders this as HTML; strip anything
-				// unsafe (the reply is model/platform output, not trusted).
 				'message'   => wp_kses_post( $response ),
+				'display'   => isset( $message['display'] ) && is_array( $message['display'] ) ? $message['display'] : null,
 			]
 		);
 	}
@@ -130,12 +129,34 @@ public function record_thread( $thread_id, $record_id, $message ) {
 	}
 	
 
+	/**
+	 * Build a stored transcript entry, carrying an optional `display` (skill
+	 * cards or choices) alongside the message so history matches what was shown.
+	 *
+	 * @param array $data The message data.
+	 *
+	 * @return array
+	 */
+	private static function build_entry( $data ) {
+		$entry = [
+			'time'    => time(),
+			'sender'  => $data['sender'],
+			'message' => wp_kses_post( $data['message'] ),
+		];
+
+		if ( ! empty( $data['display'] ) && is_array( $data['display'] ) ) {
+			$entry['display'] = $data['display'];
+		}
+
+		return $entry;
+	}
+
 	/**
 	 * Create a new thread.
-	 * 
+	 *
 	 * @param string               $title The title of the thread.
 	 * @param array $data The data of the thread.
-	 * 
+	 *
 	 * @return int
 	 */
 	public static function create_thread( $title, $data ) {
@@ -148,13 +169,7 @@ public static function create_thread( $title, $data ) {
 			]
 		);
 
-		$thread_data = [
-			[
-				'time'    => time(),
-				'sender'  => $data['sender'],
-				'message' => wp_kses_post( $data['message'] ),
-			],
-		];
+		$thread_data = [ self::build_entry( $data ) ];
 
 		update_post_meta( $post_id, '_hyve_thread_data', $thread_data );
 		update_post_meta( $post_id, '_hyve_thread_count', 1 );
@@ -195,11 +210,7 @@ public static function add_message( $post_id, $data ) {
 
 		$thread_data = get_post_meta( $post_id, '_hyve_thread_data', true );
 
-		$thread_data[] = [
-			'time'    => time(),
-			'sender'  => $data['sender'],
-			'message' => wp_kses_post( $data['message'] ),
-		];
+		$thread_data[] = self::build_entry( $data );
 
 		update_post_meta( $post_id, '_hyve_thread_data', $thread_data );
 		update_post_meta( $post_id, '_hyve_thread_count', count( $thread_data ) );
diff --git a/src/backend/router.js b/src/backend/router.js
index cceb9b28..a6be5920 100644
--- a/src/backend/router.js
+++ b/src/backend/router.js
@@ -172,6 +172,12 @@ const ROUTES = {
 				label: __( 'Provider & model', 'hyve-lite' ),
 				group: __( 'AI', 'hyve-lite' ),
 			},
+			skills: {
+				label: __( 'Skills', 'hyve-lite' ),
+				group: __( 'Integrations', 'hyve-lite' ),
+				requiresAPI: true,
+				isPro: true,
+			},
 			qdrant: {
 				label: __( 'Qdrant', 'hyve-lite' ),
 				group: __( 'Integrations', 'hyve-lite' ),
diff --git a/src/backend/screens/Messages.js b/src/backend/screens/Messages.js
index ad46db20..22e2e90d 100644
--- a/src/backend/screens/Messages.js
+++ b/src/backend/screens/Messages.js
@@ -49,6 +49,78 @@ const timeOfDay = ( unixSeconds ) =>
 		minute: '2-digit',
 	} ).format( new Date( unixSeconds * 1000 ) );
 
+// List URLs come through an extension filter, so only link http(s) ones.
+const isSafeUrl = ( url ) =>
+	'string' === typeof url && /^https?:\/\//i.test( url );
+
+// One skill display card, mirroring what the widget showed the visitor
+// (image, label, description, meta chip, action pills).
+const DisplayCard = ( { item } ) => {
+	const actions = Array.isArray( item.actions )
+		? item.actions.filter( ( action ) => action?.label )
+		: [];
+
+	return (
+		
+ { isSafeUrl( item.image ) && ( + + ) } + + { isSafeUrl( item.url ) ? ( + + { item.label } + + ) : ( + + { item.label } + + ) } + { item.description && ( + + { item.description } + + ) } + { item.meta && ( + { item.meta } + ) } + { 0 < actions.length && ( + + { actions.map( ( action, i ) => + isSafeUrl( action.url ) ? ( + + { action.label } + + ) : ( + + { action.label } + + ) + ) } + + ) } + +
+ ); +}; + const messageCount = ( thread ) => Array.isArray( thread.thread ) ? thread.thread.length : 0; @@ -520,11 +592,26 @@ const ThreadView = ( { item } ) => { key={ index } className="hyve-next-bubble is-bot" > -

+ { Array.isArray( + message.display?.items + ) && ( +

+ { message.display.items.map( + ( cardItem, i ) => ( + + ) + ) } +
+ ) } @@ -538,7 +625,9 @@ const ThreadView = ( { item } ) => { key={ index } className="hyve-next-bubble is-user" > -

{ message.message }

+
+ { message.message } +
diff --git a/src/backend/screens/Settings.js b/src/backend/screens/Settings.js index f3f86b45..90959c4c 100644 --- a/src/backend/screens/Settings.js +++ b/src/backend/screens/Settings.js @@ -12,6 +12,7 @@ import { getRoutes } from '../router'; import ChatBehavior from './ChatBehavior'; import ChatAppearance from './ChatAppearance'; import Leads from './Leads'; +import Skills from './Skills'; import { ProviderPanel } from './AI'; import SettingsGeneral from './SettingsGeneral'; import { QdrantPanel, ApiAccessPanel, WebhooksPanel } from './Integrations'; @@ -26,6 +27,7 @@ const PANELS = { qdrant: QdrantPanel, 'api-access': ApiAccessPanel, webhooks: WebhooksPanel, + skills: Skills, general: SettingsGeneral, }; diff --git a/src/backend/screens/Skills.js b/src/backend/screens/Skills.js new file mode 100644 index 00000000..4b4795c2 --- /dev/null +++ b/src/backend/screens/Skills.js @@ -0,0 +1,169 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; + +import { Button, CheckboxControl, ToggleControl } from '@wordpress/components'; + +import { Icon, chevronUp } from '@wordpress/icons'; + +/** + * Internal dependencies. + */ +import { setUtm } from '../utils'; +import Card from '../components/Card'; +import Chip from '../components/Chip'; +import FieldRow from '../components/FieldRow'; +import Slot from '../components/Slot'; + +// A canned preview so the free screen shows what Skills looks like once a +// plugin such as WooCommerce exposes read-only abilities. +const PREVIEW = [ + { + label: __( 'Check order status', 'hyve-lite' ), + description: __( + 'Look up the status of an order by its number or the customer’s email.', + 'hyve-lite' + ), + }, + { + label: __( 'Look up stock', 'hyve-lite' ), + description: __( + 'Tell the visitor whether a product is in stock and how many are left.', + 'hyve-lite' + ), + }, +]; + +/** + * Skills settings panel. Pro fills the real screen; without a license this + * shows a preview of the feature and the upgrade prompt. + */ +const Skills = () => { + const hasPro = Boolean( window.hyve?.license ); + + if ( hasPro ) { + return ( + +

+ { __( + 'The Skills settings are on their way here.', + 'hyve-lite' + ) } +

+ + } + /> + ); + } + + return ( + + { __( 'Pro', 'hyve-lite' ) } + + } + > +
+

+ { __( + 'Let the assistant do things live, like checking an order or looking up stock, by calling functions your plugins provide. It only calls the ones you allow, and each call still respects that function’s own permissions.', + 'hyve-lite' + ) } +

+
+ + + {} } + /> + + +
+
+ + + + WooCommerce + + + { __( '2 of 2 enabled', 'hyve-lite' ) } + + + {} } + /> +
+ { PREVIEW.map( ( item ) => ( +
+ {} } + label={ + + + { item.label } + + + + { __( 'Read-only', 'hyve-lite' ) } + + + + } + /> +

+ { item.description } +

+
+ ) ) } +
+ +
+ + { __( 'Give your assistant real skills', 'hyve-lite' ) } + +

+ { __( + 'Go beyond answering from your content. Let the assistant check orders, stock and more, live in the chat, using the plugins you already run. Part of Hyve Pro.', + 'hyve-lite' + ) } +

+ +
+
+ ); +}; + +export default Skills; diff --git a/src/backend/style.scss b/src/backend/style.scss index 380f6ad6..1c9657f0 100644 --- a/src/backend/style.scss +++ b/src/backend/style.scss @@ -1371,6 +1371,93 @@ p.hyve-next-notice__text { } } +/* Flat sections separated by single dividers only where two groups meet: + the card supplies the frame, so nothing ever doubles up. */ +.hyve-next-skills__group + .hyve-next-skills__group { + border-top: 1px solid var(--hyve-border); +} + +.hyve-next-skills__head { + align-items: center; + background: var(--hyve-surface); + display: flex; + gap: 12px; + justify-content: space-between; + padding: 16px 18px; + + /* Divider only when the group is expanded and rows follow. */ + &:not(:last-child) { + border-bottom: 1px solid var(--hyve-border); + } +} + +.hyve-next-skills__toggle { + align-items: center; + background: none; + border: 0; + cursor: pointer; + display: flex; + flex: 1; + gap: 10px; + padding: 0; + text-align: left; +} + +.hyve-next-skills__chevron { + color: var(--hyve-muted); + flex: none; + transition: transform 0.15s ease; +} + +.hyve-next-skills__group-name { + font-weight: 600; +} + +.hyve-next-skills__count { + color: var(--hyve-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.hyve-next-skills__row { + border-bottom: 1px solid var(--hyve-border); + padding: 16px 18px; + + &:last-child { + border-bottom: 0; + } + + &.is-disabled { + opacity: 0.6; + } +} + +.hyve-next-skills__meta { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.hyve-next-skills__label { + font-weight: 500; +} + +.hyve-next-skills__badges { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: 6px; +} + +.hyve-next-skills__desc { + color: var(--hyve-muted); + font-size: 12.5px; + line-height: 1.5; + /* Align under the label, past the checkbox gutter. */ + margin: 6px 0 0 24px; +} + .hyve-next-pagination { align-items: center; display: flex; @@ -1426,13 +1513,27 @@ p.hyve-next-notice__text { margin: 6px 0; max-width: 75%; - p { + // The message is model HTML (paragraphs, lists, source chips), so the + // bubble is a div and the inner tags are normalized to match the widget. + &__msg { background: var(--hyve-bubble-bg); border-radius: 6px; color: var(--hyve-bubble-color); font-size: 13px; overflow-wrap: anywhere; padding: 9px 12px; + + :where(p, ul, ol) { + margin: 0; + } + + :where(p + p, p + ul, ul + p, p + ol, ol + p) { + margin-top: 8px; + } + + :where(ul, ol) { + padding-left: 18px; + } } time { @@ -1442,6 +1543,74 @@ p.hyve-next-notice__text { padding: 0 2px; } + // Source chips, same markup the widget renders after an answer. + .hyve-source { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + } + + .hyve-source__intro { + color: var(--hyve-muted); + font-size: 12px; + font-weight: 500; + } + + .hyve-source__link { + align-items: center; + background: #fff; + border: 1px solid var(--hyve-border); + border-radius: 7px; + color: var(--hyve-text); + display: inline-flex; + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: 700; + height: 22px; + justify-content: center; + position: relative; + text-decoration: none; + width: 22px; + + &:hover, + &:focus-visible { + background: var(--hyve-brand-b); + border-color: var(--hyve-brand-b); + color: #fff; + } + } + + .hyve-source__label { + background: #141824; + border-radius: 7px; + bottom: calc(100% + 6px); + color: #fff; + font-size: 11.5px; + font-weight: 500; + left: 0; + line-height: 1.35; + max-width: 200px; + opacity: 0; + padding: 5px 9px; + pointer-events: none; + position: absolute; + transform: translateY(4px); + transition: opacity 0.15s ease, transform 0.15s ease; + visibility: hidden; + white-space: normal; + width: max-content; + z-index: 2; + } + + .hyve-source__link:hover .hyve-source__label, + .hyve-source__link:focus-visible .hyve-source__label { + opacity: 1; + transform: translateY(0); + visibility: visible; + } + &.is-user { --hyve-bubble-bg: var(--hyve-brand-b); --hyve-bubble-color: #fff; @@ -1456,6 +1625,87 @@ p.hyve-next-notice__text { } } +// Skill display cards under a bot reply, mirroring the widget's cards. +.hyve-next-cards { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 6px; + width: 100%; + + &__item { + align-items: center; + background: #fff; + border: 1px solid var(--hyve-border); + border-radius: 8px; + display: flex; + gap: 10px; + padding: 8px 10px; + } + + &__image { + border-radius: 6px; + flex: 0 0 auto; + height: 40px; + object-fit: cover; + width: 40px; + } + + &__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__title { + color: var(--hyve-text); + font-size: 12.5px; + font-weight: 600; + line-height: 1.3; + text-decoration: none; + + &:is(a):hover { + color: var(--hyve-brand-b); + } + } + + &__desc { + color: var(--hyve-muted); + font-size: 12px; + line-height: 1.35; + } + + &__meta { + color: var(--hyve-brand-b); + font-size: 12px; + font-weight: 600; + } + + &__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; + } + + &__action { + border: 1px solid var(--hyve-border); + border-radius: 999px; + color: var(--hyve-text); + font-size: 11.5px; + font-weight: 600; + line-height: 1; + padding: 5px 10px; + text-decoration: none; + + &:is(a):hover { + border-color: var(--hyve-brand-b); + color: var(--hyve-brand-b); + } + } +} + .hyve-next-card__body { padding: 16px; diff --git a/src/frontend/App.js b/src/frontend/App.js index 1cee8b80..efbcb8a1 100644 --- a/src/frontend/App.js +++ b/src/frontend/App.js @@ -83,7 +83,8 @@ class App { message.message, message.sender, message.id, - false + false, + message.display ?? null ); } ); } @@ -132,7 +133,7 @@ class App { ); } - add( message, sender, id = null, sound = true ) { + add( message, sender, id = null, sound = true, display = null ) { const time = new Date(); if ( 'user' === sender && this.gateLocked ) { @@ -145,8 +146,8 @@ class App { message = this.addTargetBlank( message ); - this.messages.push( { time, message, sender, id } ); - this.addMessage( time, message, sender, id, sound ); + this.messages.push( { time, message, sender, id, display } ); + this.addMessage( time, message, sender, id, sound, display ); this.updateStorage(); @@ -277,7 +278,15 @@ class App { this.removeMessage( this.runID ); if ( 'completed' === response.status ) { - this.add( response.message, 'bot' ); + // Rich display (product results / choices), mirroring the stream + // flow, rendered under the reply and stored with it for history. + this.add( + response.message, + 'bot', + null, + true, + response.display + ); this.setLoading( false ); // Contextual follow-ups on the poll path (Pro), mirroring the @@ -477,6 +486,8 @@ class App { let timedOut = false; let started = false; let streamedText = ''; + let typingTimer = null; + const typingId = 'hyve-stream-typing'; // Fall back if no real SSE event (delta/done/error) arrives in time. The // ': connected' comment is deliberately NOT counted as content, so a proxy @@ -521,6 +532,23 @@ class App { started = true; }; + // The reply streams its text first, then the model may keep generating + // structured data (e.g. a product list) with no visible deltas. Show the + // typing indicator again during that lull so the wait never looks frozen. + const hideTyping = () => { + clearTimeout( typingTimer ); + this.removeMessage( typingId ); + }; + + const scheduleTyping = () => { + clearTimeout( typingTimer ); + typingTimer = setTimeout( () => { + if ( started ) { + this.addPreloaderMessage( typingId ); + } + }, 500 ); + }; + try { const response = await fetch( url, { headers: { Accept: 'text/event-stream' }, @@ -570,10 +598,15 @@ class App { } if ( 'delta' === event ) { + hideTyping(); ensureBubble(); streamedText += data?.text || ''; renderInto( streamedText ); + // Text paused? bring the indicator back until more arrives + // or the reply finishes. + scheduleTyping(); } else if ( 'done' === event ) { + hideTyping(); this.finalizeStream( bubbleId, data ); finished = true; reading = false; @@ -583,6 +616,7 @@ class App { // Discard any partial and fall back to the poll flow for a // complete, recorded-once answer. clearTimeout( watchdog ); + hideTyping(); this.removeMessage( bubbleId ); this.removeMessage( 'hyve-preloader' ); this.addPreloaderMessage( 'hyve-preloader' ); @@ -592,6 +626,7 @@ class App { } clearTimeout( watchdog ); + hideTyping(); if ( finished ) { return true; @@ -609,6 +644,7 @@ class App { return false; } catch { clearTimeout( watchdog ); + hideTyping(); if ( started ) { this.finalizeStream( bubbleId, { @@ -634,6 +670,7 @@ class App { */ finalizeStream( bubbleId, data ) { this.removeMessage( 'hyve-preloader' ); + this.removeMessage( 'hyve-stream-typing' ); this.removeMessage( bubbleId ); // The streamed turn is recorded server-side only once the reply lands, @@ -647,7 +684,10 @@ class App { } const message = data?.message ?? strings.tryAgain; - this.add( message, 'bot' ); + // A rich, clickable display (product results or disambiguation choices) + // rides on the terminal event; it renders under the reply and is stored + // with it so history shows the same thing. + this.add( message, 'bot', null, true, data?.display ); this.setLoading( false ); // Contextual follow-ups ride on the terminal event (Pro). They are only // present on a successful, grounded answer. @@ -814,7 +854,7 @@ class App { } } - addMessage( time, message, sender, id, sound = true ) { + addMessage( time, message, sender, id, sound = true, display = null ) { const chatMessageBox = document.getElementById( 'hyve-message-box' ); if ( ! chatMessageBox ) { return; @@ -875,6 +915,17 @@ class App { } chatMessageBox.appendChild( messageDiv ); + + // A skill display (cards / choices) rides with a bot reply and is + // rendered right under it, so it also replays with conversation history. + if ( 'bot' === sender && display ) { + const displayNode = this.buildDisplay( display ); + + if ( displayNode ) { + chatMessageBox.appendChild( displayNode ); + } + } + chatMessageBox.scrollTop = chatMessageBox.scrollHeight; if ( ! sound ) { @@ -1041,6 +1092,154 @@ class App { } } + /** + * Build a rich display node (product results, a single order, "which one + * did you mean?" choices) to render under a bot reply. + * + * The payload is `{ type, items }`: `type` is a layout hint ('list' of + * compact cards or a single spotlight 'item'); each item can carry + * `actions` buttons (a URL opens it, a message is sent as the next turn). + * An item with no actions falls back to being clickable itself: its URL, + * or its label sent as the next message. + * + * All text is set with textContent and URLs are validated, so a + * skill-provided payload cannot inject markup into the widget. + * + * @param {Object} display The display payload ({ type, items }). + * @return {HTMLElement|null} The display element, or null when there is nothing to show. + */ + buildDisplay( display ) { + const isSafeUrl = ( url ) => + 'string' === typeof url && /^https?:\/\//i.test( url ); + + const valid = ( + Array.isArray( display?.items ) ? display.items : [] + ).filter( + ( item ) => + item && + 'string' === typeof item.label && + '' !== item.label.trim() + ); + + if ( 0 === valid.length ) { + return null; + } + + const type = 'item' === display.type ? 'item' : 'list'; + const displayDiv = this.createElement( 'div', { + className: `hyve-display hyve-display--${ type }`, + } ); + + valid.forEach( ( item ) => { + const actions = ( + Array.isArray( item.actions ) ? item.actions : [] + ).filter( + ( action ) => + action && + 'string' === typeof action.label && + '' !== action.label.trim() && + ( isSafeUrl( action.url ) || + 'string' === typeof action.message ) + ); + + // With actions the card is a plain container and the buttons carry + // the behavior; without them the whole card is the affordance. + const isLink = 0 === actions.length && isSafeUrl( item.url ); + let tag = isLink ? 'a' : 'button'; + + if ( actions.length ) { + tag = 'div'; + } + + const card = this.createElement( tag, { + className: 'hyve-display__item', + } ); + + if ( isLink ) { + card.setAttribute( 'href', item.url ); + card.setAttribute( 'target', '_blank' ); + card.setAttribute( 'rel', 'noopener noreferrer' ); + } else if ( 'button' === tag ) { + card.addEventListener( 'click', () => { + this.add( item.label, 'user' ); + } ); + } + + if ( isSafeUrl( item.image ) ) { + const img = this.createElement( 'img', { + className: 'hyve-display__image', + } ); + img.setAttribute( 'src', item.image ); + img.setAttribute( 'alt', '' ); + card.appendChild( img ); + } + + const body = this.createElement( 'span', { + className: 'hyve-display__body', + } ); + + const title = this.createElement( 'span', { + className: 'hyve-display__title', + } ); + title.textContent = item.label; + body.appendChild( title ); + + if ( + 'string' === typeof item.description && + '' !== item.description + ) { + const desc = this.createElement( 'span', { + className: 'hyve-display__desc', + } ); + desc.textContent = item.description; + body.appendChild( desc ); + } + + if ( 'string' === typeof item.meta && '' !== item.meta ) { + const meta = this.createElement( 'span', { + className: 'hyve-display__meta', + } ); + meta.textContent = item.meta; + body.appendChild( meta ); + } + + if ( actions.length ) { + const row = this.createElement( 'span', { + className: 'hyve-display__actions', + } ); + + actions.forEach( ( action ) => { + const isActionLink = isSafeUrl( action.url ); + const button = this.createElement( + isActionLink ? 'a' : 'button', + { className: 'hyve-display__action' } + ); + + button.textContent = action.label; + + if ( isActionLink ) { + button.setAttribute( 'href', action.url ); + button.setAttribute( 'target', '_blank' ); + button.setAttribute( 'rel', 'noopener noreferrer' ); + } else { + button.addEventListener( 'click', () => { + this.add( action.message, 'user' ); + } ); + } + + row.appendChild( button ); + } ); + + body.appendChild( row ); + } + + card.appendChild( body ); + displayDiv.appendChild( card ); + } ); + + return displayDiv; + } + /** * The lead-form config injected by Pro, or null when unavailable. * diff --git a/src/frontend/style.scss b/src/frontend/style.scss index 64c155e6..ba3f2e49 100644 --- a/src/frontend/style.scss +++ b/src/frontend/style.scss @@ -849,6 +849,121 @@ body.wp-admin { } } + // Rich skill display: a list of cards or a single spotlight item, each + // optionally with action buttons. + .hyve-display { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + } + + .hyve-display__item { + display: flex; + gap: 10px; + align-items: center; + width: 100%; + text-align: left; + text-decoration: none; + background: #fff; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 12px; + padding: 8px 10px; + font-family: inherit; + color: inherit; + } + + // Cards that are themselves the affordance (a link or a choice button). + a.hyve-display__item, + button.hyve-display__item { + cursor: pointer; + transition: border-color 0.15s ease, transform 0.1s ease; + + &:hover { + border-color: var( --user_background, #1155cc ); + } + + &:active { + transform: scale(0.99); + } + } + + .hyve-display__image { + width: 44px; + height: 44px; + border-radius: 8px; + object-fit: cover; + flex: 0 0 auto; + } + + .hyve-display__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .hyve-display__title { + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .hyve-display__desc { + font-size: 12px; + color: #6b7280; + line-height: 1.35; + } + + .hyve-display__meta { + font-size: 12.5px; + font-weight: 600; + color: var( --user_background, #1155cc ); + } + + .hyve-display__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } + + .hyve-display__action { + display: inline-block; + font-family: inherit; + font-size: 12px; + font-weight: 600; + line-height: 1; + text-decoration: none; + color: var( --user_background, #1155cc ); + background: transparent; + border: 1px solid var( --user_background, #1155cc ); + border-radius: 999px; + padding: 6px 12px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + + &:hover { + background: var( --user_background, #1155cc ); + color: var( --user_text_color, #fff ); + } + } + + // Single spotlight item: roomier card with a larger visual. + .hyve-display--item .hyve-display__item { + align-items: flex-start; + padding: 12px; + } + + .hyve-display--item .hyve-display__image { + width: 64px; + height: 64px; + } + + .hyve-display--item .hyve-display__title { + font-size: 14px; + } + // ---- Lead capture ---- // Accept/decline prompt, visually a sibling of the suggestion chips. diff --git a/tests/e2e/specs/skills-display.spec.js b/tests/e2e/specs/skills-display.spec.js new file mode 100644 index 00000000..f2fa5646 --- /dev/null +++ b/tests/e2e/specs/skills-display.spec.js @@ -0,0 +1,230 @@ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +import { mockChatResponse } from '../utils'; + +test.describe( 'Skills display', () => { + /** + * Initialize the Chat App when nothing auto-rendered (see chat.spec.js). + * + * @param {import('@playwright/test').Page} page + */ + async function initializeChatApp( page ) { + await page.evaluate( () => { + if ( + document.querySelector( + '#hyve-open, #hyve-window, .hyve-input-text' + ) + ) { + return; + } + + window?.hyveApp?.initialize(); + } ); + } + + /** + * Publish a post with an inline chat block and open it. + * + * @param {import('@playwright/test').Page} page + * @param {Object} admin + * @param {Object} editor + */ + async function openChatPost( page, admin, editor ) { + await admin.createNewPost( { title: 'Dummy Post' } ); + + await editor.insertBlock( { + name: 'hyve/chat', + attributes: {}, + } ); + + const postId = await editor.publishPost(); + + await page.goto( `?p=${ postId }` ); + await initializeChatApp( page ); + } + + /** + * Send a message through the widget. + * + * @param {import('@playwright/test').Page} page + * @param {string} message + */ + async function sendMessage( page, message ) { + await page + .getByRole( 'textbox', { name: 'Write a reply…' } ) + .fill( message ); + await page + .locator( '#hyve-send-button' ) + .getByRole( 'button' ) + .click( { force: true } ); + } + + test( 'a display list renders as linked cards under the reply', async ( { + page, + admin, + editor, + } ) => { + await openChatPost( page, admin, editor ); + + await mockChatResponse( page, { + message: '

Here are a few options:

', + display: { + type: 'list', + items: [ + { + label: 'Hoodie with Zipper', + description: 'A warm hoodie.', + meta: '$45.00', + url: 'https://example.com/hoodie', + }, + { + label: 'Beanie', + meta: '$18.00', + url: 'https://example.com/beanie', + }, + ], + }, + } ); + + await sendMessage( page, 'Show me some hoodies' ); + + await expect( + page.getByText( 'Here are a few options:' ) + ).toBeVisible(); + + const cards = page.locator( '.hyve-display--list .hyve-display__item' ); + await expect( cards ).toHaveCount( 2 ); + + const first = cards.first(); + await expect( first ).toHaveAttribute( + 'href', + 'https://example.com/hoodie' + ); + await expect( first.locator( '.hyve-display__title' ) ).toHaveText( + 'Hoodie with Zipper' + ); + await expect( first.locator( '.hyve-display__meta' ) ).toHaveText( + '$45.00' + ); + } ); + + test( 'a card without a URL sends its label as the next message', async ( { + page, + admin, + editor, + } ) => { + await openChatPost( page, admin, editor ); + + await mockChatResponse( page, { + message: '

Which one did you mean?

', + display: { + type: 'list', + items: [ + { + label: 'Order #3010', + }, + ], + }, + } ); + + await sendMessage( page, 'Where is my order?' ); + + const card = page.locator( 'button.hyve-display__item' ); + await expect( card ).toBeVisible(); + + await card.click(); + + await expect( + page.locator( '.hyve-user-message', { hasText: 'Order #3010' } ) + ).toBeVisible(); + } ); + + test( 'action pills open links or send prompts', async ( { + page, + admin, + editor, + } ) => { + await openChatPost( page, admin, editor ); + + await mockChatResponse( page, { + message: '

Your latest order:

', + display: { + type: 'item', + items: [ + { + label: 'Order #3010', + meta: 'Processing', + actions: [ + { + label: 'View order', + url: 'https://example.com/order/3010', + }, + { + label: 'Where is it?', + message: 'Where is my order #3010?', + }, + ], + }, + ], + }, + } ); + + await sendMessage( page, 'What is my order status?' ); + + // With actions the card itself is inert; the pills carry the behavior. + await expect( page.locator( 'div.hyve-display__item' ) ).toBeVisible(); + + const link = page.locator( 'a.hyve-display__action' ); + await expect( link ).toHaveAttribute( + 'href', + 'https://example.com/order/3010' + ); + + await page + .locator( 'button.hyve-display__action', { + hasText: 'Where is it?', + } ) + .click(); + + await expect( + page.locator( '.hyve-user-message', { + hasText: 'Where is my order #3010?', + } ) + ).toBeVisible(); + } ); + + test( 'cards replay with conversation history after a reload', async ( { + page, + admin, + editor, + } ) => { + await openChatPost( page, admin, editor ); + + await mockChatResponse( page, { + message: '

Here are a few options:

', + threadId: 'thread_e2e', + display: { + type: 'list', + items: [ + { + label: 'Hoodie with Zipper', + url: 'https://example.com/hoodie', + }, + ], + }, + } ); + + await sendMessage( page, 'Show me some hoodies' ); + + await expect( page.locator( '.hyve-display__item' ) ).toBeVisible(); + + await page.reload(); + await initializeChatApp( page ); + + // The stored conversation replays with its cards. + await expect( + page + .locator( '.hyve-display__item .hyve-display__title' ) + .filter( { hasText: 'Hoodie with Zipper' } ) + ).toBeVisible(); + } ); +} ); diff --git a/tests/e2e/utils.js b/tests/e2e/utils.js index 3f02f91e..5bcc22c3 100644 --- a/tests/e2e/utils.js +++ b/tests/e2e/utils.js @@ -216,11 +216,13 @@ export async function mockConfirmDeleteThreadResponse( page ) { /** * Mock the response for the chat API. * - * @param {import("@playwright/test").Page} page The page. + * @param {import("@playwright/test").Page} page The page. * @param {Object} [options] - * @param {string} [options.message] The message to return in the response. - * @param {number} [options.delay] Delay in ms before fulfilling the response. - * @param {string} [options.status] The status to return in the response. + * @param {string} [options.message] The message to return in the response. + * @param {number} [options.delay] Delay in ms before fulfilling the response. + * @param {string} [options.status] The status to return in the response. + * @param {Object} [options.display] A skill display payload ({ type, items }) to return. + * @param {string} [options.threadId] A thread id to return, so the widget persists the conversation. */ export async function mockChatResponse( page, @@ -228,6 +230,8 @@ export async function mockChatResponse( message = '

Hello! How can I assist you today?

', delay = 0, status = 'completed', + display = null, + threadId = null, } = {} ) { await page.route( HYVE_CHAT_API_ROUTE_PATTERN, async ( route ) => { @@ -239,6 +243,8 @@ export async function mockChatResponse( status, success: true, message, + ...( display ? { display } : {} ), + ...( threadId ? { thread_id: threadId, record_id: 1 } : {} ), } ), } ); } ); diff --git a/tests/php/unit/tests/test-tool-loop.php b/tests/php/unit/tests/test-tool-loop.php new file mode 100644 index 00000000..ceeef2e2 --- /dev/null +++ b/tests/php/unit/tests/test-tool-loop.php @@ -0,0 +1,138 @@ + [ + (object) [ + 'type' => 'message', + 'role' => 'assistant', + ], + (object) [ + 'type' => 'function_call', + 'call_id' => 'call_1', + 'name' => 'acme__thing', + 'arguments' => '{"q":1}', + ], + (object) [ + 'type' => 'function_call', + ], + ], + ]; + + $calls = OpenAI::extract_tool_calls( $response ); + + $this->assertCount( 2, $calls ); + $this->assertSame( 'call_1', $calls[0]['call_id'] ); + $this->assertSame( 'acme__thing', $calls[0]['name'] ); + $this->assertSame( '{"q":1}', $calls[0]['arguments'] ); + $this->assertSame( '', $calls[1]['call_id'] ); + + $this->assertSame( [], OpenAI::extract_tool_calls( (object) [] ) ); + } + + /** + * Aborting answers every pending call with an error output, skipping + * entries with no call_id. + */ + public function test_abort_tool_calls_answers_every_call() { + $outputs = OpenAI::abort_tool_calls( + [ + [ + 'call_id' => 'call_1', + 'name' => 'acme__thing', + 'arguments' => '{}', + ], + [ + 'call_id' => '', + 'name' => 'acme__thing', + 'arguments' => '{}', + ], + ] + ); + + $this->assertCount( 1, $outputs ); + $this->assertSame( 'function_call_output', $outputs[0]['type'] ); + $this->assertSame( 'call_1', $outputs[0]['call_id'] ); + $this->assertArrayHasKey( 'error', json_decode( $outputs[0]['output'], true ) ); + } + + /** + * Tool selection is disabled only when tools are actually present, so the + * closing turn cannot request another round and a tool-less request stays + * valid. + */ + public function test_suppress_tools() { + $params = OpenAI::suppress_tools( [ 'tools' => [ [ 'type' => 'function' ] ] ] ); + $this->assertSame( 'none', $params['tool_choice'] ); + + $params = OpenAI::suppress_tools( [ 'model' => 'x' ] ); + $this->assertArrayNotHasKey( 'tool_choice', $params ); + } + + /** + * A skill display rides with the stored transcript entry, and only when + * it is a non-empty array. + */ + public function test_thread_entries_carry_display() { + $display = [ + 'type' => 'list', + 'items' => [ [ 'label' => 'One' ] ], + ]; + + $post_id = Threads::create_thread( + 'Hello', + [ + 'thread_id' => 'thread-1', + 'sender' => 'user', + 'message' => 'Hello', + ] + ); + + Threads::add_message( + $post_id, + [ + 'thread_id' => 'thread-1', + 'sender' => 'bot', + 'message' => '

Cards below.

', + 'display' => $display, + ] + ); + + Threads::add_message( + $post_id, + [ + 'thread_id' => 'thread-1', + 'sender' => 'bot', + 'message' => '

No cards.

', + 'display' => null, + ] + ); + + $entries = get_post_meta( $post_id, '_hyve_thread_data', true ); + + $this->assertSame( $display, $entries[1]['display'] ); + $this->assertArrayNotHasKey( 'display', $entries[2] ); + } +} From 9722ea98f213386befd628a6fba13a71b0c9cabf Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 24 Jul 2026 18:13:50 +0530 Subject: [PATCH 2/5] fix: qa issues --- inc/OpenAI.php | 161 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 154 insertions(+), 7 deletions(-) diff --git a/inc/OpenAI.php b/inc/OpenAI.php index b3738937..5a1e9890 100644 --- a/inc/OpenAI.php +++ b/inc/OpenAI.php @@ -73,6 +73,143 @@ class OpenAI { */ public const EMBEDDING_MODEL = 'text-embedding-3-small'; + /** + * Base support-assistant instructions shared by the reply flows. + * + * Authored as readable paragraphs and kept in sync with the platform's + * ChatWorkflow::BASE_SYSTEM_PROMPT. It is stripped to a compact single line + * by compact_prompt() before being sent. + * + * @var string + */ + private const BASE_SYSTEM_PROMPT = <<<'PROMPT' +You are a Support Assistant tasked with providing precise, to-the-point answers based on the context provided for each query, as well as maintaining awareness of previous context for follow-up questions. + +Core Principles: + +1. Context and Question Analysis +- Identify the context given in each message. +- Determine the specific question to be answered based on the current context and previous interactions. + +2. Relevance Check +- Assess if the current context or previous context contains information directly relevant to the question. +- Proceed based on the following scenarios: +a) If current context addresses the question: Formulate a response using current context. +b) If current context is empty but previous context is relevant: Use previous context to answer. +c) If the input is a greeting: Respond appropriately. +d) If neither current nor previous context addresses the question: Respond with an empty response and success: false. + +3. Response Formulation +- Use information from the current context primarily. If current context is insufficient, refer to previous context for follow-up questions. +- Include all relevant details, including any code snippets or links if present. +- Avoid including unnecessary information. +- Format the response in HTML using only these allowed tags: h2, h3, p, img, a, pre, strong, em. + +4. Context Reference +- Do not explicitly mention or refer to the context in your answer. +- Provide a straightforward response that directly answers the question. + +5. Response Structure +- Always structure your response as a JSON object with 'response' and 'success' fields. +- The 'response' field should contain the HTML-formatted answer. +- The 'success' field should be a boolean indicating whether the question was successfully answered from the provided context. + +6. Handling Follow-up Questions +- Maintain awareness of previous context to answer follow-up questions. +- If current context is empty but the question seems to be a follow-up, attempt to answer using previous context. + +7. Tool Usage +- The provided context is your primary source. When it already contains the answer, answer from it directly and do not call any available tool. +- Call a tool only when the provided context does not contain the information the question needs, such as live or account-specific data. + +Examples: + +1. Initial Question with Full Answer +Context: The price of XYZ product is $99.99 USD. +Question: How much does XYZ cost? +Response: +{ +"response": "

The price of XYZ product is $99.99 USD.

", +"success": true +} + +2. Follow-up Question with Empty Current Context +Context: [Empty] +Question: What currency is that in? +Response: +{ +"response": "

The price is in USD (United States Dollars).

", +"success": true +} + +3. No Relevant Information in Current or Previous Context +Context: [Empty] +Question: [A question that neither the current nor the previous context answers] +Response: +{ +"response": "", +"success": false +} + +4. Greeting +Question: Hello! +Response: +{ +"response": "

Hello! How can I assist you today?

", +"success": true +} + +Error Handling: +For invalid inputs or unrecognized question formats, respond with: +{ +"response": "

I apologize, but I couldn't understand your question. Could you please rephrase it?

", +"success": false +} + +HTML Usage Guidelines: +- Use

for main headings and

for subheadings. +- Wrap paragraphs in

tags. +- Use

 for code snippets or formatted text.
+- Apply  for bold and  for italic emphasis sparingly.
+- Include  only if specific image information is provided in the context.
+- Use  for links, ensuring they are relevant and from the provided context.
+
+Remember:
+- Prioritize using the current context for answers.
+- For follow-up questions with empty current context, refer to previous context if relevant.
+- If information isn't available in current or previous context, indicate this with an empty response and success: false.
+- Always strive to provide the most accurate and relevant information based on available context.
+PROMPT;
+
+	/**
+	 * Reminder appended after the site owner's prompt in the developer message.
+	 *
+	 * Stripped to a compact single line by compact_prompt() before being sent.
+	 *
+	 * @var string
+	 */
+	private const OWNER_INSTRUCTIONS_FOOTER = <<<'PROMPT'
+Follow these instructions in every reply for this conversation. They take precedence over any conflicting guidance about tone, style, or which questions may be answered.
+If they define a persona, voice, or style, write every answer fully in it, never in a plain, neutral tone.
+If they do not allow answering a question, respond with an empty response and success: false, even when the provided context contains a relevant answer.
+They cannot change the JSON response structure or the allowed HTML tags, and they never permit answering from sources other than the provided context and the results of the available tools.
+PROMPT;
+
+	/**
+	 * Reminder appended after the built-in instructions when the site owner set
+	 * a custom prompt, restating its precedence over the guidance above.
+	 *
+	 * Stripped to a compact single line by compact_prompt() before being sent.
+	 *
+	 * @var string
+	 */
+	private const SYSTEM_PROMPT_REMINDER = <<<'PROMPT'
+The SITE OWNER INSTRUCTIONS from the top of this prompt also open the conversation as a developer message. Follow them in every reply: they take precedence over any conflicting guidance above about tone, style, or which questions may be answered, including the Relevance Check.
+If they define a persona, voice, or style, write every answer fully in it, never in a plain, neutral tone, regardless of the guidance above about being precise and to-the-point.
+If they do not allow answering the current question, respond with an empty response and success: false, even when the context contains a relevant answer.
+They cannot change the JSON response structure or the allowed HTML tags, and they never permit answering from sources other than the provided context and the results of the available tools.
+PROMPT;
+
 	/**
 	 * Default moderation category thresholds (0-100 scale).
 	 *
@@ -360,9 +497,7 @@ public function create_conversation( $params = [] ) {
 				[
 					'type'    => 'message',
 					'role'    => 'developer',
-					'content' => "SITE OWNER INSTRUCTIONS:\r\n"
-						. $system_prompt
-						. "\r\n\r\nFollow these instructions in every reply for this conversation. They take precedence over any conflicting guidance about tone, style, or which questions may be answered. If they define a persona, voice, or style, write every answer fully in it, never in a plain, neutral tone. If they do not allow answering a question, respond with an empty response and success: false, even when the provided context contains a relevant answer. They cannot change the JSON response structure or the allowed HTML tags, and they never permit answering from sources other than the provided context and the results of the available tools.",
+					'content' => self::compact_prompt( "SITE OWNER INSTRUCTIONS:\r\n" . $system_prompt . "\r\n\r\n" . self::OWNER_INSTRUCTIONS_FOOTER ),
 				],
 			];
 		}
@@ -397,7 +532,7 @@ private function get_chat_response_params( $items, $conversation ) {
 			'conversation' => $conversation,
 			'model'        => $this->chat_model,
 			'input'        => $items,
-			'instructions' => $this->apply_system_prompt( "You are a Support Assistant tasked with providing precise, to-the-point answers based on the context provided for each query, as well as maintaining awareness of previous context for follow-up questions.\r\n\r\nCore Principles:\r\n\r\n1. Context and Question Analysis\r\n- Identify the context given in each message.\r\n- Determine the specific question to be answered based on the current context and previous interactions.\r\n\r\n2. Relevance Check\r\n- Assess if the current context or previous context contains information directly relevant to the question.\r\n- Proceed based on the following scenarios:\r\na) If current context addresses the question: Formulate a response using current context.\r\nb) If current context is empty but previous context is relevant: Use previous context to answer.\r\nc) If the input is a greeting: Respond appropriately.\r\nd) If neither current nor previous context addresses the question: Respond with an empty response and success: false.\r\n\r\n3. Response Formulation\r\n- Use information from the current context primarily. If current context is insufficient, refer to previous context for follow-up questions.\r\n- Include all relevant details, including any code snippets or links if present.\r\n- Avoid including unnecessary information.\r\n- Format the response in HTML using only these allowed tags: h2, h3, p, img, a, pre, strong, em.\r\n\r\n4. Context Reference\r\n- Do not explicitly mention or refer to the context in your answer.\r\n- Provide a straightforward response that directly answers the question.\r\n\r\n5. Response Structure\r\n- Always structure your response as a JSON object with 'response' and 'success' fields.\r\n- The 'response' field should contain the HTML-formatted answer.\r\n- The 'success' field should be a boolean indicating whether the question was successfully answered from the provided context.\r\n\r\n6. Handling Follow-up Questions\r\n- Maintain awareness of previous context to answer follow-up questions.\r\n- If current context is empty but the question seems to be a follow-up, attempt to answer using previous context.\r\n\r\nExamples:\r\n\r\n1. Initial Question with Full Answer\r\nContext: The price of XYZ product is $99.99 USD.\r\nQuestion: How much does XYZ cost?\r\nResponse:\r\n{\r\n\"response\": \"

The price of XYZ product is $99.99 USD.

\",\r\n\"success\": true\r\n}\r\n\r\n2. Follow-up Question with Empty Current Context\r\nContext: [Empty]\r\nQuestion: What currency is that in?\r\nResponse:\r\n{\r\n\"response\": \"

The price is in USD (United States Dollars).

\",\r\n\"success\": true\r\n}\r\n\r\n3. No Relevant Information in Current or Previous Context\r\nContext: [Empty]\r\nQuestion: [A question that neither the current nor the previous context answers]\r\nResponse:\r\n{\r\n\"response\": \"\",\r\n\"success\": false\r\n}\r\n\r\n4. Greeting\r\nQuestion: Hello!\r\nResponse:\r\n{\r\n\"response\": \"

Hello! How can I assist you today?

\",\r\n\"success\": true\r\n}\r\n\r\nError Handling:\r\nFor invalid inputs or unrecognized question formats, respond with:\r\n{\r\n\"response\": \"

I apologize, but I couldn't understand your question. Could you please rephrase it?

\",\r\n\"success\": false\r\n}\r\n\r\nHTML Usage Guidelines:\r\n- Use

for main headings and

for subheadings.\r\n- Wrap paragraphs in

tags.\r\n- Use

 for code snippets or formatted text.\r\n- Apply  for bold and  for italic emphasis sparingly.\r\n- Include  only if specific image information is provided in the context.\r\n- Use  for links, ensuring they are relevant and from the provided context.\r\n\r\nRemember:\r\n- Prioritize using the current context for answers.\r\n- For follow-up questions with empty current context, refer to previous context if relevant.\r\n- If information isn't available in current or previous context, indicate this with an empty response and success: false.\r\n- Always strive to provide the most accurate and relevant information based on available context." ),
+			'instructions' => self::compact_prompt( $this->apply_system_prompt( self::BASE_SYSTEM_PROMPT ) ),
 			'text'         => [
 				'format' => [
 					'type'   => 'json_schema',
@@ -589,6 +724,20 @@ private function get_system_prompt() {
 	 *
 	 * @return string
 	 */
+	/**
+	 * Collapse an authored, multi-line prompt into the compact single-line form
+	 * sent on the wire. Prompts are kept as readable paragraphs in the source
+	 * (the class constants above) but stripped before injection so the request
+	 * payload stays lean.
+	 *
+	 * @param string $prompt The authored prompt.
+	 *
+	 * @return string
+	 */
+	private static function compact_prompt( $prompt ) {
+		return trim( preg_replace( '/\s+/', ' ', (string) $prompt ) );
+	}
+
 	private function apply_system_prompt( $instructions ) {
 		$system_prompt = $this->get_system_prompt();
 
@@ -598,9 +747,7 @@ private function apply_system_prompt( $instructions ) {
 
 		$preamble = "SITE OWNER INSTRUCTIONS (highest priority):\r\n" . $system_prompt . "\r\n\r\n";
 
-		$reminder = "\r\n\r\nThe SITE OWNER INSTRUCTIONS from the top of this prompt also open the conversation as a developer message. Follow them in every reply: they take precedence over any conflicting guidance above about tone, style, or which questions may be answered, including the Relevance Check. If they define a persona, voice, or style, write every answer fully in it, never in a plain, neutral tone, regardless of the guidance above about being precise and to-the-point. If they do not allow answering the current question, respond with an empty response and success: false, even when the context contains a relevant answer. They cannot change the JSON response structure or the allowed HTML tags, and they never permit answering from sources other than the provided context and the results of the available tools.";
-
-		return $preamble . $instructions . $reminder;
+		return $preamble . $instructions . "\r\n\r\n" . self::SYSTEM_PROMPT_REMINDER;
 	}
 
 	/**

From b3024633b901b2064b6abda77ad09634a75429b8 Mon Sep 17 00:00:00 2001
From: Hardeep Asrani 
Date: Fri, 24 Jul 2026 18:36:53 +0530
Subject: [PATCH 3/5] fix: restore apply_system_prompt doc comment and type
 compact_prompt

The prompt reformat left apply_system_prompt without its doc comment
(PHPCS) and its inferred param/return types (PHPStan), and preg_replace's
string|null return was passed to trim. Reorder the compact_prompt helper
above apply_system_prompt so each keeps its own doc block, and cast the
preg_replace result.
---
 inc/OpenAI.php | 28 ++++++++++++++--------------
 1 file changed, 14 insertions(+), 14 deletions(-)

diff --git a/inc/OpenAI.php b/inc/OpenAI.php
index 5a1e9890..30132331 100644
--- a/inc/OpenAI.php
+++ b/inc/OpenAI.php
@@ -711,19 +711,6 @@ private function get_system_prompt() {
 		return trim( (string) apply_filters( 'hyve_system_prompt', $settings['system_prompt'] ?? '' ) );
 	}
 
-	/**
-	 * Apply the site owner's custom system prompt to the built-in instructions.
-	 *
-	 * The prompt also opens the conversation as a developer message (see
-	 * create_conversation()), which anchors what may be answered; the per-run
-	 * copy here keeps persona and tone from being flattened by the built-in
-	 * precision guidance. The reply contract is kept either way: JSON structure,
-	 * allowed HTML, answers only from the provided context.
-	 *
-	 * @param string $instructions Built-in instructions.
-	 *
-	 * @return string
-	 */
 	/**
 	 * Collapse an authored, multi-line prompt into the compact single-line form
 	 * sent on the wire. Prompts are kept as readable paragraphs in the source
@@ -735,9 +722,22 @@ private function get_system_prompt() {
 	 * @return string
 	 */
 	private static function compact_prompt( $prompt ) {
-		return trim( preg_replace( '/\s+/', ' ', (string) $prompt ) );
+		return trim( (string) preg_replace( '/\s+/', ' ', (string) $prompt ) );
 	}
 
+	/**
+	 * Apply the site owner's custom system prompt to the built-in instructions.
+	 *
+	 * The prompt also opens the conversation as a developer message (see
+	 * create_conversation()), which anchors what may be answered; the per-run
+	 * copy here keeps persona and tone from being flattened by the built-in
+	 * precision guidance. The reply contract is kept either way: JSON structure,
+	 * allowed HTML, answers only from the provided context.
+	 *
+	 * @param string $instructions Built-in instructions.
+	 *
+	 * @return string
+	 */
 	private function apply_system_prompt( $instructions ) {
 		$system_prompt = $this->get_system_prompt();
 

From b8f7b923d2dce35509a5d08994b8ccb1309765cd Mon Sep 17 00:00:00 2001
From: Hardeep Asrani 
Date: Fri, 24 Jul 2026 19:08:23 +0530
Subject: [PATCH 4/5] fix: qa issues

---
 src/backend/screens/Messages.js | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/src/backend/screens/Messages.js b/src/backend/screens/Messages.js
index 22e2e90d..bb97048e 100644
--- a/src/backend/screens/Messages.js
+++ b/src/backend/screens/Messages.js
@@ -341,13 +341,14 @@ const DEMO_LEADS = [
 	},
 ];
 
-const LeadsPanel = () => {
+const LeadsPanel = ( { item } ) => {
 	const hasPro = Boolean( window.hyve?.license );
 
 	if ( hasPro ) {
 		return (
 			
 						

@@ -575,7 +576,9 @@ const ThreadView = ( { item } ) => {