PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / engines / google-interactions.php
ai-engine / classes / engines Last commit date
anthropic.php 5 hours ago chatml.php 1 week ago core.php 2 days ago custom.php 1 month ago factory.php 1 week ago google-interactions.php 2 days ago google.php 1 week ago mistral.php 1 week ago open-router.php 3 weeks ago openai.php 3 weeks ago ovh.php 1 week ago perplexity.php 6 months ago replicate.php 5 months ago xai.php 1 month ago
google-interactions.php
741 lines
1 <?php
2
3 /**
4 * Google Gemini — Interactions API engine.
5 *
6 * As of June 2026 the Interactions API is GA and Google's recommended interface
7 * (https://ai.google.dev/gemini-api/docs/interactions-overview). It is stateful
8 * (server-side history via previous_interaction_id), exposes Google's built-in
9 * tools (Google Search, Google Maps, URL context, code execution), and returns a
10 * chronological list of execution "steps" (model_output / function_call /
11 * function_result / thoughts).
12 *
13 * This is the DEFAULT engine for Google environments. The classic
14 * generateContent engine (Meow_MWAI_Engines_Google) remains the fallback,
15 * selected when "Use Standard API" (google_use_standard_api) is enabled in
16 * Settings > AI > General. The request/response field names are mapped from
17 * Google's docs and confirmed against the live API.
18 */
19 class Meow_MWAI_Engines_GoogleInteractions extends Meow_MWAI_Engines_Core {
20 protected $apiKey = null;
21 protected $endpoint = null;
22
23 // Streaming accumulators (collected from SSE events, used to build the Reply).
24 protected $streamId = null;
25 protected $streamInTokens = null;
26 protected $streamOutTokens = null;
27 protected $streamToolCalls = [];
28 protected $streamImages = [];
29
30 // The classic Google engine, used for everything the Interactions API does not
31 // cover (embeddings, image generation, transcription) and for model fetching.
32 protected $classicEngine = null;
33
34 public function __construct( $core, $env ) {
35 parent::__construct( $core, $env );
36 $this->set_environment();
37 }
38
39 protected function set_environment() {
40 $env = $this->env;
41 $this->apiKey = $env['apikey'];
42 $this->endpoint = apply_filters(
43 'mwai_google_endpoint',
44 'https://generativelanguage.googleapis.com/v1beta',
45 $this->env
46 );
47 }
48
49 protected function build_headers( $query = null ) {
50 // A query may carry a per-request API key override (set via inject_params);
51 // fall back to the environment key otherwise.
52 $apiKey = ( $query !== null && !empty( $query->apiKey ) ) ? $query->apiKey : $this->apiKey;
53 return [
54 'Content-Type' => 'application/json',
55 'x-goog-api-key' => $apiKey,
56 ];
57 }
58
59 #region Request building
60
61 /**
62 * Build the "input" for the request.
63 * - Feedback queries carry function results back as function_result items.
64 * - When we have a previous interaction id, the server holds the history so we
65 * only send the new user turn.
66 * - Otherwise we send the conversation as Content[].
67 */
68 protected function user_step( $query ) {
69 $content = [];
70
71 // Attached images / files (vision).
72 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
73 foreach ( $attachments as $file ) {
74 $mime = $file->get_mimeType() ? $file->get_mimeType() : 'image/jpeg';
75 // Image content parts are flat: { type, data (base64), mime_type }.
76 $content[] = [
77 'type' => 'image',
78 'data' => $file->get_base64(),
79 'mime_type' => $mime,
80 ];
81 }
82
83 if ( $query->message !== '' || empty( $content ) ) {
84 $content[] = [ 'type' => 'text', 'text' => $query->message ];
85 }
86
87 return [ 'type' => 'user_input', 'content' => $content ];
88 }
89
90 /**
91 * The Interactions API is steps-based: input must be a step_list, not a
92 * role-based turn_list. So we emit user_input / model_output / function_result
93 * steps (the same step types the API returns).
94 */
95 protected function build_input( $query ) {
96 // Feedback: carry the function results back as function_result steps.
97 if ( $query instanceof Meow_MWAI_Query_Feedback && !empty( $query->blocks ) ) {
98 $steps = [];
99 foreach ( $query->blocks as $block ) {
100 foreach ( ( $block['feedbacks'] ?? [] ) as $feedback ) {
101 $value = $feedback['reply']['value'] ?? '';
102 if ( !is_string( $value ) ) {
103 $value = wp_json_encode( $value );
104 }
105 // The Interactions API expects `call_id` (matching the function_call
106 // step's id) and `result` as an array of content blocks, not a string.
107 $steps[] = [
108 'type' => 'function_result',
109 'call_id' => $feedback['request']['toolId'] ?? null,
110 'name' => $feedback['request']['name'] ?? '',
111 'result' => [ [ 'type' => 'text', 'text' => $value ] ],
112 ];
113 }
114 }
115 return $steps;
116 }
117
118 $steps = [];
119
120 // When continuing an interaction, prior turns live server-side (referenced by
121 // previous_interaction_id) so we only send the new user turn. On the FIRST
122 // turn the server has no state, so we must replay the conversation history
123 // ($query->messages) as user_input / model_output steps. Without this,
124 // restored discussions and any caller-supplied history are lost. The API
125 // accepts replayed model_output steps (verified live).
126 if ( !$this->has_previous_interaction( $query ) && !empty( $query->messages ) ) {
127 foreach ( $query->messages as $message ) {
128 $text = $message['content'] ?? '';
129 if ( !is_string( $text ) || $text === '' ) {
130 continue;
131 }
132 $role = $message['role'] ?? 'user';
133 $type = ( $role === 'assistant' || $role === 'model' ) ? 'model_output' : 'user_input';
134 $steps[] = [ 'type' => $type, 'content' => [ [ 'type' => 'text', 'text' => $text ] ] ];
135 }
136 }
137
138 $steps[] = $this->user_step( $query );
139 return $steps;
140 }
141
142 protected function build_tools( $query ) {
143 $tools = [];
144
145 // Custom WordPress / Code Engine functions -> function declarations.
146 // Each tool is flat: { type:function, name, description, parameters }.
147 if ( !empty( $query->functions ) ) {
148 foreach ( $query->functions as $function ) {
149 $decl = $function->serializeForOpenAI();
150 // The Interactions API validates `parameters` as a JSON Schema and is
151 // strict about it: a no-arg function's empty
152 // { type:object, properties:{}, required:[] } is rejected with the
153 // misleading "value at top-level must be a list". Send a bare object
154 // schema when there are no parameters, and drop an empty `required`
155 // list otherwise.
156 if ( empty( $function->parameters ) ) {
157 $decl['parameters'] = [ 'type' => 'object' ];
158 }
159 elseif ( empty( $decl['parameters']['required'] ) ) {
160 unset( $decl['parameters']['required'] );
161 }
162 $tools[] = array_merge( [ 'type' => 'function' ], $decl );
163 }
164 }
165
166 // Provider built-in tools. AI Engine uses the generic 'web_search' name
167 // across providers; for Gemini it maps to Google Search grounding (the
168 // classic generateContent engine maps it the same way). Google Maps
169 // grounding is named-places only for now (no lat/long context), so "near me"
170 // queries won't resolve but explicit places do.
171 //
172 // Google Search and Google Maps are mutually exclusive in one Interactions
173 // request (the API rejects "cannot be combined in the same request"). When a
174 // chatbot has both enabled, prefer Maps (the more specific opt-in tool).
175 if ( in_array( 'google_maps', $query->tools, true ) ) {
176 $tools[] = [ 'type' => 'google_maps' ];
177 }
178 elseif ( in_array( 'web_search', $query->tools, true ) ) {
179 $tools[] = [ 'type' => 'google_search' ];
180 }
181
182 return $tools;
183 }
184
185 protected function build_interaction_body( $query, $streamCallback = null ) {
186 $body = [
187 'model' => $query->model,
188 'input' => $this->build_input( $query ),
189 'store' => true,
190 ];
191
192 if ( !is_null( $streamCallback ) ) {
193 $body['stream'] = true;
194 }
195
196 if ( $this->has_previous_interaction( $query ) ) {
197 $body['previous_interaction_id'] = $query->previousResponseId;
198 }
199
200 // System instructions, plus any per-query context (RAG / content-aware /
201 // Smart Search). Context is re-retrieved each turn and is not part of the
202 // server-side history, so it must be sent on every request, not just the
203 // first one. Without this, embeddings and content-aware context are silently
204 // dropped on Gemini.
205 $instructions = !empty( $query->instructions ) ? $query->instructions : '';
206 if ( !empty( $query->context ) ) {
207 $framedContext = $this->core->frame_context( $query->context );
208 $instructions = trim( $instructions . "\n\n" . $framedContext );
209 }
210 if ( $instructions !== '' ) {
211 $body['system_instruction'] = $instructions;
212 }
213
214 $tools = $this->build_tools( $query );
215 if ( !empty( $tools ) ) {
216 $body['tools'] = $tools;
217 }
218
219 $generationConfig = [];
220 if ( $query->temperature !== null ) {
221 $generationConfig['temperature'] = $query->temperature;
222 }
223 if ( !empty( $query->maxTokens ) ) {
224 $generationConfig['max_output_tokens'] = $query->maxTokens;
225 }
226 if ( !empty( $generationConfig ) ) {
227 $body['generation_config'] = $generationConfig;
228 }
229
230 // Image-capable Gemini models (Flash Image) only return images when the
231 // request opts into image output via the top-level response_format; without
232 // it they answer in text ("I can't output an image in this chat"). The
233 // dedicated image-query path is unaffected (it runs on the classic engine).
234 if ( $this->model_supports_image_generation( $query->model ) ) {
235 $responseFormat = [ 'type' => 'image' ];
236 if ( !empty( $query->resolution ) ) {
237 $responseFormat['aspect_ratio'] = $query->resolution;
238 }
239 $body['response_format'] = $responseFormat;
240 }
241
242 return apply_filters( 'mwai_google_interactions_body', $body, $query );
243 }
244
245 /**
246 * Whether the model can emit images in a chat turn (the 'image-generation'
247 * feature, e.g. Gemini Flash Image). Used to opt the request into image
248 * output via response_format.
249 */
250 protected function model_supports_image_generation( $modelId ) {
251 $model = $this->core->find_model_data( $modelId, $this->env['id'] ?? null );
252 return !empty( $model['features'] )
253 && in_array( 'image-generation', $model['features'], true );
254 }
255
256 #endregion
257
258 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
259 // Non-image attachments (PDFs, documents) need the Gemini Files API upload
260 // handled by the classic engine's prepare_query() / build_messages() (Pro).
261 // The Interactions input format only carries inline image parts, so delegate
262 // those queries to the classic engine to preserve file handling.
263 if ( $this->has_non_image_attachment( $query ) ) {
264 return $this->classic_engine()->run_completion_query( $query, $streamCallback );
265 }
266
267 $debug = $this->core->get_option( 'queries_debug_mode' );
268 $isStreaming = !is_null( $streamCallback );
269 $this->init_debug_mode( $query );
270
271 // Reset per-request streaming state (Core buffers + our accumulators).
272 $this->streamContent = '';
273 $this->streamTemporaryBuffer = '';
274 $this->streamBuffer = '';
275 $this->reset_stream();
276
277 if ( $isStreaming ) {
278 // The Core stream_handler hooks the curl WRITEFUNCTION and dispatches each
279 // SSE "data:" line to our stream_data_handler().
280 $this->streamCallback = $streamCallback;
281 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
282 }
283
284 $body = $this->build_interaction_body( $query, $streamCallback );
285 $url = trailingslashit( $this->endpoint ) . 'interactions';
286
287 if ( $debug ) {
288 error_log( '[AI Engine Queries] --> (Gemini Interactions) ' . $url );
289 error_log( wp_json_encode( $body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
290 }
291
292 $args = [
293 'headers' => $this->build_headers( $query ),
294 'body' => wp_json_encode( $body ),
295 'timeout' => apply_filters( 'mwai_request_timeout', 120, $query ),
296 'sslverify' => MWAI_SSL_VERIFY,
297 ];
298 $tmpFile = null;
299 if ( $isStreaming ) {
300 // WordPress streams the response into this file; on error it holds the body.
301 $tmpFile = tempnam( sys_get_temp_dir(), 'mwai-gemini-stream-' );
302 $args['stream'] = true;
303 $args['filename'] = $tmpFile;
304 }
305
306 try {
307 $res = wp_remote_post( $url, $args );
308
309 if ( is_wp_error( $res ) ) {
310 throw new Exception( 'AI Engine (Gemini Interactions): ' . $res->get_error_message() );
311 }
312
313 $code = wp_remote_retrieve_response_code( $res );
314
315 if ( $isStreaming ) {
316 if ( $code < 200 || $code >= 300 ) {
317 // The error body went to the temp file rather than the response.
318 $errBody = ( $tmpFile && file_exists( $tmpFile ) )
319 ? file_get_contents( $tmpFile ) : wp_remote_retrieve_body( $res );
320 $errData = json_decode( $errBody, true );
321 $detail = $errData['error']['message'] ?? $errBody;
322 throw new Exception( 'AI Engine (Gemini Interactions) HTTP ' . $code . ': ' . $detail );
323 }
324 return $this->build_streaming_reply( $query );
325 }
326
327 $rawBody = wp_remote_retrieve_body( $res );
328 $data = json_decode( $rawBody, true );
329
330 if ( $debug ) {
331 error_log( '[AI Engine Queries] <-- (Gemini Interactions) HTTP ' . $code );
332 error_log( substr( $rawBody, 0, 4000 ) );
333 }
334
335 if ( $code < 200 || $code >= 300 ) {
336 $detail = $data['error']['message'] ?? $rawBody;
337 throw new Exception( 'AI Engine (Gemini Interactions) HTTP ' . $code . ': ' . $detail );
338 }
339
340 return $this->build_reply_from_interaction( $query, $data, $streamCallback );
341 }
342 finally {
343 if ( $isStreaming ) {
344 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
345 }
346 if ( $tmpFile && file_exists( $tmpFile ) ) {
347 unlink( $tmpFile );
348 }
349 }
350 }
351
352 public function reset_stream() {
353 $this->streamId = null;
354 $this->streamInTokens = null;
355 $this->streamOutTokens = null;
356 $this->streamToolCalls = [];
357 $this->streamImages = [];
358 }
359
360 /**
361 * Handle one parsed SSE "data:" payload from the Interactions stream. Returns a
362 * text string for text deltas (the Core accumulates it into streamContent and
363 * forwards to the chatbot), a Meow_MWAI_Event for thoughts, null for
364 * bookkeeping events (function-call assembly, completion), or throws on a
365 * provider error event.
366 *
367 * The Core's stream_handler only forwards "data:" lines and drops the "event:"
368 * line, so we dispatch on the event_type carried inside the JSON payload.
369 */
370 protected function stream_data_handler( $json ) {
371 $type = $json['event_type'] ?? '';
372
373 if ( isset( $json['error'] ) || $type === 'error' || $type === 'interaction.failed' ) {
374 $this->throw_stream_error( $json );
375 }
376
377 if ( $type === 'step.start' ) {
378 $step = $json['step'] ?? [];
379 // A function call arrives as a single step.start with the complete call
380 // (id, name, arguments). Larger argument payloads may also stream as
381 // arguments_delta events, which we accumulate below.
382 if ( ( $step['type'] ?? '' ) === 'function_call' ) {
383 $index = $json['index'] ?? count( $this->streamToolCalls );
384 $args = $step['arguments'] ?? '';
385 $argsJson = is_string( $args ) ? $args : wp_json_encode( $args );
386 $this->streamToolCalls[$index] = [
387 'id' => $step['id'] ?? null,
388 'name' => $step['name'] ?? '',
389 'arguments' => $argsJson,
390 ];
391 // Surface the call in the event-log (gated like the function_result
392 // event Core emits, so nothing streams when Event Logs is off).
393 if ( $this->currentDebugMode && $this->streamCallback ) {
394 return Meow_MWAI_Event::function_calling( $step['name'] ?? '', $argsJson );
395 }
396 }
397 return null;
398 }
399
400 if ( $type === 'step.delta' ) {
401 $delta = $json['delta'] ?? [];
402 $dtype = $delta['type'] ?? '';
403 if ( $dtype === 'text' ) {
404 return $delta['text'] ?? '';
405 }
406 if ( $dtype === 'arguments_delta' ) {
407 $index = $json['index'] ?? array_key_last( $this->streamToolCalls );
408 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
409 $this->streamToolCalls[$index]['arguments'] .= $delta['arguments'] ?? '';
410 }
411 return null;
412 }
413 // Generated image (e.g. Gemini Flash Image): the delta carries mime_type +
414 // base64 data and has no "type" field. The base64 may stream in chunks, so
415 // accumulate per step index.
416 if ( isset( $delta['mime_type'], $delta['data'] ) ) {
417 $index = $json['index'] ?? count( $this->streamImages );
418 $isFirst = !isset( $this->streamImages[$index] );
419 if ( $isFirst ) {
420 $this->streamImages[$index] = [ 'mime_type' => $delta['mime_type'], 'data' => '' ];
421 }
422 $this->streamImages[$index]['data'] .= $delta['data'];
423 if ( $isFirst && $this->currentDebugMode && $this->streamCallback ) {
424 $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['IMAGE_GEN'] );
425 $event->set_content( 'Image generated' );
426 return $event;
427 }
428 return null;
429 }
430 // Thought deltas carry an encrypted thought_signature, not readable text,
431 // so there is nothing to surface to the user; they are intentionally skipped.
432 return null;
433 }
434
435 if ( $type === 'interaction.completed' ) {
436 $interaction = $json['interaction'] ?? [];
437 if ( !empty( $interaction['id'] ) ) {
438 $this->streamId = $interaction['id'];
439 }
440 $usage = $interaction['usage'] ?? [];
441 if ( isset( $usage['total_input_tokens'] ) ) {
442 $this->streamInTokens = (int) $usage['total_input_tokens'];
443 }
444 if ( isset( $usage['total_output_tokens'] ) ) {
445 $this->streamOutTokens = (int) $usage['total_output_tokens'];
446 }
447 return null;
448 }
449
450 return null;
451 }
452
453 protected function throw_stream_error( $json ) {
454 $error = $json['error'] ?? ( $json['interaction']['error'] ?? null );
455 $message = null;
456 $code = null;
457 $status = null;
458
459 if ( is_array( $error ) ) {
460 $message = $error['message'] ?? ( $error['reason'] ?? null );
461 $code = $error['code'] ?? null;
462 $status = $error['status'] ?? ( $error['type'] ?? null );
463 }
464 elseif ( is_string( $error ) ) {
465 $message = $error;
466 }
467
468 if ( empty( $message ) ) {
469 $message = $json['message'] ?? ( $json['error_message'] ?? 'Unknown stream error.' );
470 }
471
472 $detail = $message;
473 if ( !empty( $code ) ) {
474 $detail .= ' (' . $code . ')';
475 }
476 if ( !empty( $status ) ) {
477 $detail .= ' (' . $status . ')';
478 }
479
480 throw new Exception( 'AI Engine (Gemini Interactions) stream error: ' . $detail );
481 }
482
483 /**
484 * Build the Reply from the streaming accumulators once the SSE stream is done.
485 * Mirrors build_reply_from_interaction() but sources from the streamed state.
486 */
487 protected function build_streaming_reply( $query ) {
488 $reply = new Meow_MWAI_Reply( $query );
489 $choices = [];
490
491 foreach ( $this->streamToolCalls as $tc ) {
492 $args = $tc['arguments'];
493 $decoded = ( $args === '' || $args === null ) ? [] : json_decode( $args, true );
494 if ( json_last_error() !== JSON_ERROR_NONE ) {
495 $decoded = [];
496 }
497 $choices[] = [
498 'message' => [
499 'content' => null,
500 'tool_calls' => [ [
501 'id' => $tc['id'],
502 'type' => 'function',
503 'function' => [ 'name' => $tc['name'], 'arguments' => $decoded ],
504 ] ],
505 ],
506 ];
507 }
508
509 if ( $this->streamContent !== '' ) {
510 $choices[] = [ 'role' => 'assistant', 'text' => $this->streamContent ];
511 }
512
513 // Generated images become b64_json choices; set_choices() saves each one and
514 // appends the image markdown to the reply so the chatbot renders it.
515 foreach ( $this->streamImages as $img ) {
516 if ( !empty( $img['data'] ) ) {
517 $choices[] = [ 'b64_json' => $img['data'] ];
518 }
519 }
520
521 // Nothing to show (no text, image, or function call): surface a short
522 // fallback instead of a blank bubble. This happens e.g. when a text-only
523 // model is asked to edit an image, or the model declines a request.
524 if ( empty( $choices ) ) {
525 $choices[] = [ 'role' => 'assistant', 'text' => $this->empty_reply_fallback( $query ) ];
526 }
527
528 $reply->set_choices( $choices );
529
530 if ( !empty( $this->streamId ) ) {
531 $reply->set_id( $this->streamId );
532 }
533
534 if ( $this->streamInTokens !== null || $this->streamOutTokens !== null ) {
535 $recorded = $this->core->record_tokens_usage(
536 $query->model,
537 (int) ( $this->streamInTokens ?? 0 ),
538 (int) ( $this->streamOutTokens ?? 0 )
539 );
540 $reply->set_usage( $recorded );
541 }
542
543 return $reply;
544 }
545
546 /**
547 * Message shown when the model returns nothing renderable (no text, image, or
548 * function call), so the chatbot never displays a blank bubble. Filterable.
549 */
550 protected function empty_reply_fallback( $query ) {
551 return apply_filters(
552 'mwai_google_interactions_empty_reply',
553 __( "Sorry, I couldn't produce a response for that. Please try rephrasing your request.", 'ai-engine' ),
554 $query
555 );
556 }
557
558 /**
559 * Turn an Interaction resource (id, status, steps[], usage) into a Reply.
560 * Emits step events (thoughts, function calls) to the stream callback so the
561 * chatbot's event-log UI shows what the model did.
562 */
563 protected function build_reply_from_interaction( $query, $data, $streamCallback = null ) {
564 $reply = new Meow_MWAI_Reply( $query );
565
566 $choices = [];
567 $text = '';
568 foreach ( ( $data['steps'] ?? [] ) as $step ) {
569 $type = $step['type'] ?? '';
570
571 if ( $type === 'model_output' ) {
572 foreach ( ( $step['content'] ?? [] ) as $part ) {
573 if ( ( $part['type'] ?? '' ) === 'text' ) {
574 $text .= $part['text'] ?? '';
575 }
576 // Generated image part (mime_type + base64 data) -> b64_json choice.
577 elseif ( isset( $part['mime_type'], $part['data'] ) ) {
578 $choices[] = [ 'b64_json' => $part['data'] ];
579 }
580 }
581 }
582 elseif ( $type === 'thought' ) {
583 if ( !empty( $streamCallback ) ) {
584 $thought = '';
585 foreach ( ( $step['content'] ?? [] ) as $part ) {
586 $thought .= $part['text'] ?? '';
587 }
588 if ( $thought !== '' ) {
589 $event = new Meow_MWAI_Event( 'live', 'thinking' );
590 $event->set_content( $thought );
591 call_user_func( $streamCallback, $event );
592 }
593 }
594 }
595 elseif ( $type === 'function_call' ) {
596 $name = $step['name'] ?? '';
597 $args = $step['arguments'] ?? [];
598 if ( !empty( $streamCallback ) ) {
599 $event = Meow_MWAI_Event::function_calling( $name, is_string( $args ) ? $args : wp_json_encode( $args ) );
600 call_user_func( $streamCallback, $event );
601 }
602 // Map to a tool_calls choice so set_choices() builds the needFeedback,
603 // preserving the id (required for the function_result follow-up).
604 $choices[] = [
605 'message' => [
606 'content' => null,
607 'tool_calls' => [ [
608 'id' => $step['id'] ?? null,
609 'type' => 'function',
610 'function' => [ 'name' => $name, 'arguments' => $args ],
611 ] ],
612 ],
613 '_rawMessage' => $step,
614 ];
615 }
616 }
617
618 if ( $text !== '' ) {
619 $choices[] = [ 'role' => 'assistant', 'text' => $text ];
620 }
621
622 // Surface a short fallback rather than a blank reply when there is nothing
623 // to show (no text, image, or function call).
624 if ( empty( $choices ) ) {
625 $choices[] = [ 'role' => 'assistant', 'text' => $this->empty_reply_fallback( $query ) ];
626 }
627
628 $reply->set_choices( $choices );
629
630 // Stateful handle for the next turn.
631 if ( !empty( $data['id'] ) ) {
632 $reply->set_id( $data['id'] );
633 }
634
635 // Usage.
636 $usage = $data['usage'] ?? [];
637 if ( isset( $usage['total_input_tokens'] ) || isset( $usage['total_output_tokens'] ) ) {
638 $recorded = $this->core->record_tokens_usage(
639 $query->model,
640 (int) ( $usage['total_input_tokens'] ?? 0 ),
641 (int) ( $usage['total_output_tokens'] ?? 0 )
642 );
643 $reply->set_usage( $recorded );
644 }
645
646 return $reply;
647 }
648
649 /**
650 * Interaction ids look like "v1_Chd..." (the classic generateContent API is
651 * stateless and has no id). Used to decide whether to reuse server-side state.
652 */
653 protected function has_previous_interaction( $query ) {
654 return !empty( $query->previousResponseId )
655 && is_string( $query->previousResponseId )
656 && strpos( $query->previousResponseId, 'v1_' ) === 0;
657 }
658
659 /**
660 * Dynamic model list is shared with the classic Google engine (same /models
661 * endpoint and same API key). get_models() is also called during final_checks()
662 * to validate the requested model, so it must be implemented.
663 */
664 public function get_models() {
665 return $this->core->get_engine_models( 'google' );
666 }
667
668 /**
669 * Called by the framework to price a reply. The classic engine already prices
670 * every Google model and query type correctly (text, token-based Flash Image,
671 * and per-image Imagen), so reuse it rather than keep a second partial copy.
672 * Interaction usage carries prompt_tokens/completion_tokens/total_tokens, which
673 * is exactly what the classic pricing expects.
674 */
675 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
676 return $this->classic_engine()->get_price( $query, $reply );
677 }
678
679 /**
680 * The Interactions API only covers chat completions. Everything else still
681 * runs against the classic Google endpoints (same API key and env), so we lazily
682 * build the classic engine and delegate those operations to it.
683 */
684 protected function classic_engine() {
685 if ( $this->classicEngine === null ) {
686 $this->classicEngine = Meow_MWAI_Engines_Google::create( $this->core, $this->env );
687 }
688 return $this->classicEngine;
689 }
690
691 /**
692 * Whether the query carries an attachment that is not an inline image (e.g. a
693 * PDF or document). Those need the classic engine's Files API handling.
694 */
695 protected function has_non_image_attachment( $query ) {
696 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
697 foreach ( $attachments as $file ) {
698 $mime = $file->get_mimeType();
699 if ( !empty( $mime ) && strpos( $mime, 'image/' ) !== 0 ) {
700 return true;
701 }
702 }
703 return false;
704 }
705
706 /**
707 * The base engine's connection_check() throws "not implemented"; the test runs
708 * through the factory which now returns this engine for Google envs. The check
709 * (same /models endpoint + key) is identical to the classic engine, so delegate.
710 */
711 public function connection_check() {
712 return $this->classic_engine()->connection_check();
713 }
714
715 /**
716 * Fetching + tagging the model list (the "Refresh Models" action) is identical
717 * to the classic Google engine: same /models endpoint, same API key, same tag
718 * derivation (vision, functions, web_search...). Delegate to it rather than
719 * duplicate that logic, so refreshed Gemini models carry the right tools.
720 */
721 public function retrieve_models() {
722 return $this->classic_engine()->retrieve_models();
723 }
724
725 public function run_embedding_query( Meow_MWAI_Query_Base $query ) {
726 return $this->classic_engine()->run_embedding_query( $query );
727 }
728
729 public function run_image_query( Meow_MWAI_Query_Base $query, $streamCallback = null ) {
730 return $this->classic_engine()->run_image_query( $query, $streamCallback );
731 }
732
733 public function run_editimage_query( Meow_MWAI_Query_Base $query ) {
734 return $this->classic_engine()->run_editimage_query( $query );
735 }
736
737 public function run_transcribe_query( Meow_MWAI_Query_Base $query ) {
738 return $this->classic_engine()->run_transcribe_query( $query );
739 }
740 }
741