PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 1.45 All 41 releases
fluent-boards / app / Services / AiService.php

AiService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration trunk, at app/Services/AiService.php

885 lines 33.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\Framework\Support\Arr;
6
7 /**
8 * AI writing assistant service.
9 *
10 * Mirrors the FluentCRM AI implementation so the Fluent suite stays in sync:
11 * credentials are shared across the suite via the global `_fluent_ai_creds`
12 * option, while Fluent Boards keeps its own writing preferences.
13 *
14 * Providers supported: OpenAI, Anthropic (Claude), Google Gemini and the
15 * WordPress-native AI client. All provider calls are blocking wp_remote_post
16 * requests (no streaming).
17 */
18 class AiService
19 {
20 /**
21 * Suite-wide credentials option (shared with FluentCRM).
22 * @var string
23 */
24 private $credentialsOptionKey = '_fluent_ai_creds';
25
26 /**
27 * Fluent Boards AI preferences key (stored in fbs_meta).
28 * @var string
29 */
30 private $settingsOptionKey = 'ai_features_settings';
31
32 /**
33 * Pre-release key name, read as a fallback so an existing toggle survives.
34 * @var string
35 */
36 private $legacySettingsOptionKey = 'ai_writing_settings';
37
38 private $providerModels = [
39 'wordpress' => ['wordpress'],
40 'open_ai' => ['auto', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'],
41 'claude' => ['auto', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001', 'claude-opus-4-6'],
42 'gemini' => ['auto', 'gemini-3.5-flash', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite'],
43 ];
44
45 private $autoProviderModels = [
46 'open_ai' => 'gpt-5.4',
47 'claude' => 'claude-sonnet-4-6',
48 'gemini' => 'gemini-3.5-flash',
49 'wordpress' => 'wordpress',
50 ];
51
52 /**
53 * Actions the in-editor assistant can request.
54 * @var array
55 */
56 private $validActions = ['write', 'improve', 'summarize', 'shorten', 'expand', 'format', 'fix_grammar', 'custom'];
57
58 /**
59 * Prompt size budgets (characters). Provider calls are blocking, so an
60 * unbounded payload would hold a PHP worker for the full timeout, blow past
61 * the model context limit, and burn tokens.
62 */
63 const MAX_CONTENT_CHARS = 12000;
64 const MAX_PROMPT_CHARS = 2000;
65 const MAX_CONTEXT_CHARS = 12000;
66
67 /**
68 * Whether the current site can use the WordPress-native AI client.
69 */
70 public function hasWordPressAi()
71 {
72 global $wp_version;
73 return (intval(explode('.', $wp_version)[0]) >= 7);
74 }
75
76 /**
77 * Saved settings with the API key masked for frontend display.
78 */
79 public function getDisplaySettings()
80 {
81 $settings = $this->getSavedSettings();
82
83 if (!empty($settings['api_key'])) {
84 $settings['api_key'] = '****' . substr($settings['api_key'], -4);
85 }
86
87 return $settings;
88 }
89
90 /**
91 * Persist AI settings. Returns an array on success or WP_Error on failure.
92 *
93 * @param array $data
94 * @return array|\WP_Error
95 */
96 public function saveSettings($data)
97 {
98 $isEnabled = sanitize_text_field(Arr::get($data, 'is_enabled', 'no')) === 'yes' ? 'yes' : 'no';
99 $provider = $this->normalizeProvider(sanitize_text_field(Arr::get($data, 'provider', '')));
100 $model = sanitize_text_field(Arr::get($data, 'model', 'auto'));
101 $apiKey = sanitize_text_field(Arr::get($data, 'api_key', ''));
102 $customPrompt = sanitize_textarea_field(Arr::get($data, 'custom_prompt', ''));
103
104 if ($provider === 'wordpress' && !$this->hasWordPressAi()) {
105 return new \WP_Error('unsupported', __('WordPress AI is only supported in WordPress 7.0 or higher.', 'fluent-boards'));
106 }
107
108 if ($provider && !in_array($provider, array_keys($this->providerModels), true)) {
109 return new \WP_Error('invalid_provider', __('Invalid AI provider selected.', 'fluent-boards'));
110 }
111
112 /*
113 * The credentials option is shared suite-wide — FluentCRM and other Fluent
114 * plugins read and write the same `_fluent_ai_creds` key. Fluent Boards must
115 * NEVER erase it: an empty or masked submission always keeps what is already
116 * stored, and each field only moves forward to a real new value. Clearing the
117 * shared key is intentionally not possible from this screen, because doing so
118 * would silently break AI in every other Fluent plugin on the site.
119 */
120 $existing = $this->getSavedCredentials();
121 $existingKey = (string) Arr::get($existing, 'api_key', '');
122 $existingProvider = (string) Arr::get($existing, 'provider', '');
123 $existingModel = (string) Arr::get($existing, 'model', '');
124
125 // Only a real, non-masked value replaces the stored key.
126 $plainApiKey = $existingKey;
127 if ($apiKey !== '' && strpos($apiKey, '****') !== 0) {
128 $plainApiKey = $apiKey;
129 }
130
131 // A blank submission keeps the stored provider/model rather than blanking them.
132 if (!$provider) {
133 $provider = $existingProvider;
134 }
135 if (!$model) {
136 $model = $existingModel ?: 'auto';
137 }
138
139 // Never persist a model the selected provider does not support, otherwise
140 // every later generation resolves it as-is and keeps calling the provider
141 // with an invalid model.
142 if ($provider && !in_array($model, Arr::get($this->providerModels, $provider, []), true)) {
143 return new \WP_Error('invalid_model', __('Invalid AI model selected for this provider.', 'fluent-boards'));
144 }
145
146 $credentials = [
147 'provider' => $provider,
148 'model' => $model,
149 'api_key' => $plainApiKey,
150 // Keep whichever Fluent plugin first created the shared credentials.
151 'created_by' => sanitize_text_field(Arr::get($existing, 'created_by', '')) ?: 'fluent_boards',
152 ];
153
154 // Final guard: never write a credential set that would wipe an existing key.
155 if ($existingKey !== '' && $credentials['api_key'] === '') {
156 $credentials['api_key'] = $existingKey;
157 }
158
159 // Only touch the shared option when something actually changed.
160 if ($credentials['provider'] !== $existingProvider
161 || $credentials['model'] !== $existingModel
162 || $credentials['api_key'] !== $existingKey
163 ) {
164 update_option($this->credentialsOptionKey, $credentials, false);
165 }
166 fluent_boards_update_option($this->settingsOptionKey, [
167 'is_enabled' => $isEnabled,
168 'custom_prompt' => $customPrompt,
169 ]);
170
171 return ['message' => __('AI configuration saved successfully.', 'fluent-boards')];
172 }
173
174 /**
175 * Model dropdown options for a provider.
176 *
177 * @param string $provider
178 * @return array|\WP_Error
179 */
180 public function getModelOptions($provider)
181 {
182 $provider = $this->normalizeProvider($provider);
183
184 if (!$provider || !in_array($provider, array_keys($this->providerModels), true)) {
185 return new \WP_Error('invalid_provider', __('Invalid AI provider selected.', 'fluent-boards'));
186 }
187
188 $models = [];
189 foreach (Arr::get($this->providerModels, $provider, []) as $model) {
190 $models[] = [
191 'value' => $model,
192 'label' => $model === 'auto' ? __('Auto', 'fluent-boards') : $model,
193 ];
194 }
195
196 return $models;
197 }
198
199 /**
200 * Round-trip a small probe prompt to validate credentials.
201 *
202 * @param array $data provider/model/api_key
203 * @return true|\WP_Error
204 */
205 public function testConnection($data)
206 {
207 $provider = $this->normalizeProvider(sanitize_text_field(Arr::get($data, 'provider', '')));
208 $model = sanitize_text_field(Arr::get($data, 'model', 'auto'));
209 $apiKey = sanitize_text_field(Arr::get($data, 'api_key', ''));
210
211 if (!$provider || !$model) {
212 return new \WP_Error('missing_config', __('Please select a provider and model first.', 'fluent-boards'));
213 }
214
215 if (!in_array($provider, array_keys($this->providerModels), true)) {
216 return new \WP_Error('invalid_provider', __('Invalid AI provider selected.', 'fluent-boards'));
217 }
218
219 // Use the stored key when the field still shows the masked value.
220 if (!$apiKey || strpos($apiKey, '****') === 0) {
221 $apiKey = Arr::get($this->getSavedSettings(), 'api_key', '');
222 }
223
224 if ($provider !== 'wordpress' && !$apiKey) {
225 return new \WP_Error('missing_api_key', __('Please enter an API key.', 'fluent-boards'));
226 }
227
228 $resolvedModel = $this->resolveModel($provider, $model);
229 if (!$resolvedModel) {
230 return new \WP_Error('missing_model', __('AI model is not configured. Please set it in Settings.', 'fluent-boards'));
231 }
232
233 $result = $this->callProviderApi($provider, $resolvedModel, $apiKey, 'Say "Connection successful" in exactly two words.', '', 15);
234
235 if (is_wp_error($result)) {
236 return $result;
237 }
238
239 return true;
240 }
241
242 /**
243 * Generate/transform text for the task description editor.
244 *
245 * @param string $action one of $validActions
246 * @param string $content selected text or existing description
247 * @param string $tone optional tone hint
248 * @param string $prompt user prompt for "write"/"custom" actions
249 * @param array $context optional task context (title, board name)
250 * @return string|\WP_Error markdown
251 */
252 public function generate($action, $content, $tone = '', $prompt = '', $context = [])
253 {
254 $action = sanitize_text_field($action);
255 // Bound the payload before it reaches the blocking provider request.
256 $content = $this->truncate((string) $content, self::MAX_CONTENT_CHARS);
257 $prompt = $this->truncate(sanitize_textarea_field($prompt), self::MAX_PROMPT_CHARS);
258
259 if (!in_array($action, $this->validActions, true)) {
260 return new \WP_Error('invalid_action', __('Invalid action specified.', 'fluent-boards'));
261 }
262
263 $needsPromptActions = ['write', 'custom'];
264 if (in_array($action, $needsPromptActions, true)) {
265 if (empty($prompt) && empty($content)) {
266 return new \WP_Error('no_input', __('Please provide a prompt or select some text.', 'fluent-boards'));
267 }
268 } elseif (empty($content)) {
269 return new \WP_Error('no_content', __('No content provided to process.', 'fluent-boards'));
270 }
271
272 $settings = $this->getSavedSettings();
273
274 $config = $this->validateGenerationConfig($settings);
275 if (is_wp_error($config)) {
276 return $config;
277 }
278
279 $userPrompt = $this->buildUserPrompt($action, $content, $prompt, $context);
280 $systemPrompt = $this->getSystemPrompt($tone, $settings);
281
282 $result = $this->callProviderApi($config['provider'], $config['model'], $config['api_key'], $userPrompt, $systemPrompt, 30);
283
284 if (is_wp_error($result)) {
285 return $result;
286 }
287
288 return trim((string) $result);
289 }
290
291 /* -------------------------------------------------------------------------
292 * Task intelligence (task-level actions)
293 * ---------------------------------------------------------------------- */
294
295 /**
296 * Summarize a task (title + description + comment thread) into markdown.
297 *
298 * @param array $context task_title, board_title, description, comments
299 * @return string|\WP_Error
300 */
301 public function taskSummary($context)
302 {
303 $system = 'You are a project-management assistant. Summarize the task below for a teammate who needs to get up to speed quickly. '
304 . 'Use only the supplied information — do not invent details. '
305 . 'Return concise GitHub-flavored Markdown: a one-line TL;DR, then short bullets for key points, open questions, and next steps when the content supports them. '
306 . 'No preamble, no code fences.';
307
308 $result = $this->runGeneration($system, "Summarize this task.\n\n" . $this->taskContextText($context), 45);
309 if (is_wp_error($result)) {
310 return $result;
311 }
312
313 return trim((string) $result);
314 }
315
316 /**
317 * Propose a list of subtask titles from the task content.
318 *
319 * @param array $context task_title, board_title, description
320 * @return array|\WP_Error list of sanitized title strings
321 */
322 public function taskSubtasks($context)
323 {
324 $system = 'You break a task down into clear, actionable subtasks. '
325 . 'Return ONLY a JSON array of short subtask title strings (max 10), ordered logically. '
326 . 'Each title is a concise action, no numbering, no extra prose, no code fences.';
327
328 $result = $this->runGeneration($system, "Task to break down:\n\n" . $this->taskContextText($context), 45);
329 if (is_wp_error($result)) {
330 return $result;
331 }
332
333 return $this->parseJsonList($result);
334 }
335
336 /**
337 * Suggest labels (from an allowed list) and a priority for the task.
338 *
339 * @param array $context task content
340 * @param array $allowedLabels label names the board offers
341 * @param array $allowedPriorities allowed priority keys
342 * @return array|\WP_Error ['labels' => [...names], 'priority' => key]
343 */
344 public function taskSuggestions($context, $allowedLabels, $allowedPriorities)
345 {
346 $allowedLabels = array_values(array_filter(array_map('strval', (array) $allowedLabels)));
347 $allowedPriorities = array_values(array_filter(array_map('strval', (array) $allowedPriorities)));
348
349 $labelList = $allowedLabels ? implode(', ', $allowedLabels) : '(none configured)';
350 $priorityList = implode(', ', $allowedPriorities);
351
352 $system = 'You classify project tasks. '
353 . 'Return ONLY JSON of the shape {"labels":["..."],"priority":"..."}. '
354 . 'Choose labels ONLY from the provided label list (return an empty array if none fit). '
355 . 'Choose priority ONLY from the provided priority list. No prose, no code fences.';
356
357 $user = 'Available labels: ' . $labelList . "\n"
358 . 'Available priorities: ' . $priorityList . "\n\n"
359 . "Task:\n" . $this->taskContextText($context);
360
361 $result = $this->runGeneration($system, $user, 30);
362 if (is_wp_error($result)) {
363 return $result;
364 }
365
366 return $this->parseSuggestions($result, $allowedLabels, $allowedPriorities);
367 }
368
369 /**
370 * Validate config + call the provider with a system/user prompt pair.
371 *
372 * @return string|\WP_Error
373 */
374 private function runGeneration($systemPrompt, $userPrompt, $timeout = 30)
375 {
376 $config = $this->validateGenerationConfig($this->getSavedSettings());
377 if (is_wp_error($config)) {
378 return $config;
379 }
380
381 return $this->callProviderApi($config['provider'], $config['model'], $config['api_key'], $userPrompt, $systemPrompt, $timeout);
382 }
383
384 /**
385 * Build the task context, keeping description + comments inside one shared
386 * character budget so a long task can never produce an unbounded prompt.
387 */
388 private function taskContextText($context)
389 {
390 $parts = [];
391 if ($title = trim((string) Arr::get($context, 'task_title', ''))) {
392 $parts[] = 'Title: ' . $title;
393 }
394 if ($board = trim((string) Arr::get($context, 'board_title', ''))) {
395 $parts[] = 'Board: ' . $board;
396 }
397
398 $remaining = max(0, self::MAX_CONTEXT_CHARS - $this->length(implode("\n\n", $parts)));
399
400 // The description gets up to 60% of the budget; comments take what is left.
401 if (($description = trim((string) Arr::get($context, 'description', ''))) && $remaining > 0) {
402 $description = $this->truncate($description, (int) min($remaining, self::MAX_CONTEXT_CHARS * 0.6));
403 $parts[] = "Description:\n" . $description;
404 $remaining = max(0, $remaining - $this->length($description));
405 }
406
407 if (($comments = trim((string) Arr::get($context, 'comments', ''))) && $remaining > 0) {
408 // Trim from the start so the most recent comments survive.
409 $parts[] = "Comments:\n" . $this->truncateStart($comments, $remaining);
410 }
411
412 return implode("\n\n", $parts);
413 }
414
415 private function length($text)
416 {
417 return function_exists('mb_strlen') ? mb_strlen((string) $text) : strlen((string) $text);
418 }
419
420 /**
421 * Keep the beginning of the text, dropping the overflow.
422 */
423 private function truncate($text, $limit)
424 {
425 $text = (string) $text;
426 if ($limit <= 0 || $this->length($text) <= $limit) {
427 return $text;
428 }
429
430 $cut = function_exists('mb_substr') ? mb_substr($text, 0, $limit) : substr($text, 0, $limit);
431
432 return $cut . "\n… [truncated]";
433 }
434
435 /**
436 * Keep the end of the text (most recent content), dropping the older overflow.
437 */
438 private function truncateStart($text, $limit)
439 {
440 $text = (string) $text;
441 if ($limit <= 0 || $this->length($text) <= $limit) {
442 return $text;
443 }
444
445 $cut = function_exists('mb_substr') ? mb_substr($text, -$limit) : substr($text, -$limit);
446
447 return "… [older content truncated]\n" . $cut;
448 }
449
450 private function parseJsonList($result)
451 {
452 $decoded = json_decode(trim((string) $result), true);
453 if (!is_array($decoded) && preg_match('/\[.*\]/s', (string) $result, $m)) {
454 $decoded = json_decode($m[0], true);
455 }
456 if (!is_array($decoded)) {
457 return [];
458 }
459
460 $items = [];
461 foreach ($decoded as $item) {
462 if (is_string($item)) {
463 $title = $item;
464 } elseif (is_array($item)) {
465 $title = Arr::get($item, 'title', '');
466 } else {
467 continue;
468 }
469 $title = sanitize_text_field(trim((string) $title));
470 if ($title !== '') {
471 $items[] = $title;
472 }
473 }
474
475 return array_slice($items, 0, 15);
476 }
477
478 private function parseSuggestions($result, $allowedLabels, $allowedPriorities)
479 {
480 $decoded = json_decode(trim((string) $result), true);
481 if (!is_array($decoded) && preg_match('/\{.*\}/s', (string) $result, $m)) {
482 $decoded = json_decode($m[0], true);
483 }
484 if (!is_array($decoded)) {
485 $decoded = [];
486 }
487
488 $labels = [];
489 foreach ((array) Arr::get($decoded, 'labels', []) as $label) {
490 $label = trim((string) $label);
491 foreach ($allowedLabels as $allowed) {
492 if (strtolower($allowed) === strtolower($label)) {
493 $labels[] = $allowed;
494 break;
495 }
496 }
497 }
498
499 $priority = sanitize_text_field((string) Arr::get($decoded, 'priority', ''));
500 if (!in_array($priority, $allowedPriorities, true)) {
501 $priority = '';
502 }
503
504 return [
505 'labels' => array_values(array_unique($labels)),
506 'priority' => $priority,
507 ];
508 }
509
510 /**
511 * @return array{provider:string,model:string,api_key:string}|\WP_Error
512 */
513 private function validateGenerationConfig($settings)
514 {
515 if (Arr::get($settings, 'is_enabled') !== 'yes') {
516 return new \WP_Error('ai_disabled', __('AI features are not enabled. Please configure AI in Settings.', 'fluent-boards'));
517 }
518
519 $provider = Arr::get($settings, 'provider', '');
520 $apiKey = Arr::get($settings, 'api_key', '');
521 $model = Arr::get($settings, 'model', '');
522
523 if (!$provider || !in_array($provider, array_keys($this->providerModels), true)) {
524 return new \WP_Error('missing_provider', __('AI provider is not configured. Please set it in Settings.', 'fluent-boards'));
525 }
526
527 if ($provider !== 'wordpress' && !$apiKey) {
528 return new \WP_Error('missing_api_key', __('AI API key is not configured. Please add it in Settings.', 'fluent-boards'));
529 }
530
531 $resolvedModel = $this->resolveModel($provider, $model);
532 if (!$resolvedModel) {
533 return new \WP_Error('missing_model', __('AI model is not configured. Please set it in Settings.', 'fluent-boards'));
534 }
535
536 return [
537 'provider' => $provider,
538 'model' => $resolvedModel,
539 'api_key' => $apiKey,
540 ];
541 }
542
543 private function getSystemPrompt($tone = '', $settings = [])
544 {
545 $prompt = 'You are a writing assistant embedded in a project-management task editor. '
546 . 'You help write and improve task descriptions. Write clearly and concisely for a work context. '
547 . 'Return ONLY the resulting text in GitHub-flavored Markdown. '
548 . 'Do not add explanations, preamble, or wrap the answer in code fences. '
549 . 'Use headings, bullet lists, and checklists (- [ ]) where they make the description clearer. '
550 . 'Keep any existing Markdown structure intact unless asked to change it.';
551
552 if ($tone) {
553 $prompt .= ' Use a ' . strtolower(sanitize_text_field($tone)) . ' tone.';
554 }
555
556 $custom = trim((string) Arr::get($settings, 'custom_prompt', ''));
557 if ($custom) {
558 $prompt .= "\n\nAdditional instructions: " . $custom;
559 }
560
561 return $prompt;
562 }
563
564 private function buildUserPrompt($action, $content, $prompt, $context = [])
565 {
566 $contextText = $this->buildContextText($context);
567
568 switch ($action) {
569 case 'write':
570 $base = "Write a task description based on this instruction:\n\n" . ($prompt ?: $content);
571 if ($content && $prompt) {
572 $base .= "\n\nExisting text for reference:\n" . $content;
573 }
574 return $contextText . $base;
575 case 'improve':
576 return $contextText . "Improve the following task description. Make it clearer and better structured while keeping the meaning:\n\n" . $content;
577 case 'summarize':
578 return $contextText . "Summarize the following task description into a short, clear TL;DR:\n\n" . $content;
579 case 'shorten':
580 return $contextText . "Make the following task description shorter and more concise:\n\n" . $content;
581 case 'expand':
582 return $contextText . "Expand the following task description with more helpful detail:\n\n" . $content;
583 case 'format':
584 return $contextText . "Reformat the following task description into clean Markdown with headings, bullet points, and a checklist where appropriate. Do not change the meaning:\n\n" . $content;
585 case 'fix_grammar':
586 return $contextText . "Fix grammar, spelling, and punctuation in the following text. Keep the wording and Markdown otherwise unchanged:\n\n" . $content;
587 case 'custom':
588 return $contextText . $prompt . ($content ? "\n\nText:\n" . $content : '');
589 default:
590 return $content;
591 }
592 }
593
594 private function buildContextText($context)
595 {
596 if (!is_array($context) || empty($context)) {
597 return '';
598 }
599
600 $lines = [];
601 if ($title = sanitize_text_field(Arr::get($context, 'task_title', ''))) {
602 $lines[] = 'Task title: ' . $title;
603 }
604 if ($board = sanitize_text_field(Arr::get($context, 'board_title', ''))) {
605 $lines[] = 'Board: ' . $board;
606 }
607
608 if (!$lines) {
609 return '';
610 }
611
612 return "Context for the task you are helping with:\n" . implode("\n", $lines) . "\n\n";
613 }
614
615 /* -------------------------------------------------------------------------
616 * Provider dispatch
617 * ---------------------------------------------------------------------- */
618
619 private function callProviderApi($provider, $model, $apiKey, $userPrompt, $systemPrompt = '', $timeout = 30)
620 {
621 switch ($provider) {
622 case 'open_ai':
623 return $this->callOpenAi($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
624 case 'claude':
625 return $this->callClaude($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
626 case 'gemini':
627 return $this->callGemini($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
628 case 'wordpress':
629 return $this->callWordPress($model, $userPrompt, $systemPrompt, $timeout);
630 default:
631 return new \WP_Error('invalid_provider', __('Invalid AI provider.', 'fluent-boards'));
632 }
633 }
634
635 private function callWordPress($model, $userPrompt, $systemPrompt, $timeout)
636 {
637 $filtered = apply_filters('fluent_boards/wordpress_ai_generate', null, $userPrompt, $systemPrompt, $model, $timeout);
638 if ($filtered !== null) {
639 return $filtered;
640 }
641
642 if (function_exists('wp_ai_client_prompt')) {
643 $prompt = wp_ai_client_prompt($userPrompt);
644 if ($systemPrompt) {
645 $prompt->using_system_instruction($systemPrompt);
646 }
647 if ($prompt->is_supported_for_text_generation()) {
648 $result = $prompt->generate_text();
649 if (is_wp_error($result)) {
650 return $result;
651 }
652 if (empty($result)) {
653 return new \WP_Error('empty_response', __('No content generated by WordPress AI client. Please try again.', 'fluent-boards'));
654 }
655 return $result;
656 }
657 return new \WP_Error('not_supported', __('WordPress AI client is not configured or supported on this site.', 'fluent-boards'));
658 }
659
660 return new \WP_Error(
661 'wordpress_ai_not_supported',
662 __('WordPress AI Client functions are not available on this WordPress installation. Please ensure you have an AI provider plugin or WordPress AI Core features enabled.', 'fluent-boards')
663 );
664 }
665
666 private function callOpenAi($model, $apiKey, $userPrompt, $systemPrompt, $timeout)
667 {
668 $messages = [];
669 if ($systemPrompt) {
670 $messages[] = ['role' => 'system', 'content' => $systemPrompt];
671 }
672 $messages[] = ['role' => 'user', 'content' => $userPrompt];
673
674 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
675 'timeout' => $timeout,
676 'headers' => [
677 'Authorization' => 'Bearer ' . $apiKey,
678 'Content-Type' => 'application/json',
679 ],
680 'body' => wp_json_encode([
681 'model' => $model,
682 'messages' => $messages,
683 'max_completion_tokens' => 2048,
684 ]),
685 ]);
686
687 if (is_wp_error($response)) {
688 return new \WP_Error('api_error', __('Failed to connect to OpenAI: ', 'fluent-boards') . $response->get_error_message());
689 }
690
691 $code = wp_remote_retrieve_response_code($response);
692 $body = json_decode(wp_remote_retrieve_body($response), true);
693
694 if ($code !== 200) {
695 return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from OpenAI.', 'fluent-boards')));
696 }
697
698 $content = Arr::get($body, 'choices.0.message.content', '');
699 if (empty($content)) {
700 return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
701 }
702
703 return $content;
704 }
705
706 private function callClaude($model, $apiKey, $userPrompt, $systemPrompt, $timeout)
707 {
708 $data = [
709 'model' => $model,
710 'max_tokens' => 2048,
711 'messages' => [
712 ['role' => 'user', 'content' => $userPrompt],
713 ],
714 ];
715
716 if ($systemPrompt) {
717 $data['system'] = $systemPrompt;
718 }
719
720 $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
721 'timeout' => $timeout,
722 'headers' => [
723 'x-api-key' => $apiKey,
724 'anthropic-version' => '2023-06-01',
725 'Content-Type' => 'application/json',
726 ],
727 'body' => wp_json_encode($data),
728 ]);
729
730 if (is_wp_error($response)) {
731 return new \WP_Error('api_error', __('Failed to connect to Claude: ', 'fluent-boards') . $response->get_error_message());
732 }
733
734 $code = wp_remote_retrieve_response_code($response);
735 $body = json_decode(wp_remote_retrieve_body($response), true);
736
737 if ($code !== 200) {
738 return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from Claude.', 'fluent-boards')));
739 }
740
741 $content = Arr::get($body, 'content.0.text', '');
742 if (empty($content)) {
743 return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
744 }
745
746 return $content;
747 }
748
749 private function callGemini($model, $apiKey, $userPrompt, $systemPrompt, $timeout)
750 {
751 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent';
752
753 $data = [
754 'contents' => [
755 ['parts' => [['text' => $userPrompt]]],
756 ],
757 'generationConfig' => [
758 'maxOutputTokens' => 2048,
759 ],
760 ];
761
762 if ($systemPrompt) {
763 $data['system_instruction'] = ['parts' => [['text' => $systemPrompt]]];
764 }
765
766 $response = wp_remote_post($url, [
767 'timeout' => $timeout,
768 'headers' => [
769 'Content-Type' => 'application/json',
770 'x-goog-api-key' => $apiKey,
771 ],
772 'body' => wp_json_encode($data),
773 ]);
774
775 if (is_wp_error($response)) {
776 return new \WP_Error('api_error', __('Failed to connect to Gemini: ', 'fluent-boards') . $response->get_error_message());
777 }
778
779 $code = wp_remote_retrieve_response_code($response);
780 $body = json_decode(wp_remote_retrieve_body($response), true);
781
782 if ($code !== 200) {
783 return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from Gemini.', 'fluent-boards')));
784 }
785
786 $content = Arr::get($body, 'candidates.0.content.parts.0.text', '');
787 if (empty($content)) {
788 return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
789 }
790
791 return $content;
792 }
793
794 /* -------------------------------------------------------------------------
795 * Storage helpers
796 * ---------------------------------------------------------------------- */
797
798 private function getSavedSettings()
799 {
800 $defaults = [
801 'is_enabled' => 'no',
802 'provider' => '',
803 'api_key' => '',
804 'model' => 'auto',
805 'custom_prompt' => '',
806 ];
807
808 $settings = array_merge($this->getSavedPreferences(), $this->getSavedCredentials());
809
810 return wp_parse_args($settings, $defaults);
811 }
812
813 private function getSavedCredentials()
814 {
815 $credentials = get_option($this->credentialsOptionKey, []);
816 if (!is_array($credentials)) {
817 $credentials = [];
818 }
819
820 $model = sanitize_text_field(Arr::get($credentials, 'model', 'auto'));
821
822 return [
823 'provider' => $this->normalizeProvider(Arr::get($credentials, 'provider', '')),
824 'model' => $model ?: 'auto',
825 'api_key' => sanitize_text_field(Arr::get($credentials, 'api_key', '')),
826 'created_by' => sanitize_text_field(Arr::get($credentials, 'created_by', '')),
827 ];
828 }
829
830 private function getSavedPreferences()
831 {
832 $preferences = fluent_boards_get_option($this->settingsOptionKey, []);
833 if (!is_array($preferences) || empty($preferences)) {
834 // Fall back to the pre-release key so an existing toggle is not lost.
835 $legacy = fluent_boards_get_option($this->legacySettingsOptionKey, []);
836 $preferences = is_array($legacy) ? $legacy : [];
837 }
838
839 return [
840 'is_enabled' => sanitize_text_field(Arr::get($preferences, 'is_enabled', 'no')) === 'yes' ? 'yes' : 'no',
841 'custom_prompt' => sanitize_textarea_field(Arr::get($preferences, 'custom_prompt', '')),
842 ];
843 }
844
845 private function normalizeProvider($provider)
846 {
847 $provider = sanitize_key($provider);
848 return $provider === 'openai' ? 'open_ai' : $provider;
849 }
850
851 private function resolveModel($provider, $model)
852 {
853 $model = $model ?: 'auto';
854
855 if ($model !== 'auto') {
856 return $model;
857 }
858
859 return Arr::get($this->autoProviderModels, $provider, '');
860 }
861
862 /**
863 * Whether AI writing is enabled and configured (used to gate the editor UI).
864 */
865 public function isReady()
866 {
867 $settings = $this->getSavedSettings();
868
869 if (Arr::get($settings, 'is_enabled') !== 'yes') {
870 return false;
871 }
872
873 $provider = Arr::get($settings, 'provider', '');
874 if (!$provider) {
875 return false;
876 }
877
878 if ($provider !== 'wordpress' && !Arr::get($settings, 'api_key', '')) {
879 return false;
880 }
881
882 return true;
883 }
884 }
885