PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 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 All 43 releases
fluent-boards / app / Services / AiService.php

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

1,085 lines 42.4 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, OpenRouter
15 * and the 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-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'],
41 'claude' => ['auto', 'claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1', 'claude-sonnet-5', 'claude-opus-5', 'claude-haiku-4-5-20251001', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-opus-4-6'],
42 'gemini' => ['auto', 'gemini-3.8-flash', 'gemini-3.6-flash', 'gemini-3.7-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite', 'gemini-3.1-flash-lite', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite'],
43 'openrouter' => [],
44 ];
45
46 /**
47 * Auto picks the newest model first, then a widely available stable one.
48 * Newer models are often gated by account tier, verification or region, so
49 * fallback requires a typed 404 identifying the unavailable model.
50 */
51 private $autoProviderModels = [
52 'open_ai' => ['gpt-6.1-sol', 'gpt-5.6-terra'],
53 'claude' => ['claude-sonnet-5-5', 'claude-sonnet-5'],
54 'gemini' => ['gemini-3.8-flash', 'gemini-3.5-flash'],
55 'wordpress' => ['wordpress'],
56 ];
57
58 /**
59 * Actions the in-editor assistant can request.
60 * @var array
61 */
62 private $validActions = ['write', 'improve', 'summarize', 'shorten', 'expand', 'format', 'fix_grammar', 'custom'];
63
64 /**
65 * Prompt size budgets (characters). Provider calls are blocking, so an
66 * unbounded payload would hold a PHP worker for the full timeout, blow past
67 * the model context limit, and burn tokens.
68 */
69 const MAX_CONTENT_CHARS = 12000;
70 const MAX_PROMPT_CHARS = 2000;
71 const MAX_CONTEXT_CHARS = 12000;
72
73 /**
74 * Whether the current site can use the WordPress-native AI client.
75 */
76 public function hasWordPressAi()
77 {
78 global $wp_version;
79 return (intval(explode('.', $wp_version)[0]) >= 7);
80 }
81
82 /**
83 * Saved settings with the API key masked for frontend display.
84 */
85 public function getDisplaySettings()
86 {
87 $settings = $this->getSavedSettings();
88
89 if (!empty($settings['api_key'])) {
90 $settings['api_key'] = '****' . substr($settings['api_key'], -4);
91 }
92
93 return $settings;
94 }
95
96 /**
97 * Persist AI settings. Returns an array on success or WP_Error on failure.
98 *
99 * @param array $data
100 * @return array|\WP_Error
101 */
102 public function saveSettings($data)
103 {
104 $isEnabled = sanitize_text_field(Arr::get($data, 'is_enabled', 'no')) === 'yes' ? 'yes' : 'no';
105 $provider = $this->normalizeProvider(sanitize_text_field(Arr::get($data, 'provider', '')));
106 $model = sanitize_text_field(Arr::get($data, 'model', 'auto'));
107 $apiKey = sanitize_text_field(Arr::get($data, 'api_key', ''));
108 $customPrompt = sanitize_textarea_field(Arr::get($data, 'custom_prompt', ''));
109
110 if ($provider === 'wordpress' && !$this->hasWordPressAi()) {
111 return new \WP_Error('unsupported', __('WordPress AI is only supported in WordPress 7.0 or higher.', 'fluent-boards'));
112 }
113
114 if ($provider && !in_array($provider, array_keys($this->providerModels), true)) {
115 return new \WP_Error('invalid_provider', __('Invalid AI provider selected.', 'fluent-boards'));
116 }
117
118 /*
119 * The credentials option is shared suite-wide — FluentCRM and other Fluent
120 * plugins read and write the same `_fluent_ai_creds` key. Fluent Boards must
121 * NEVER erase it: an empty or masked submission always keeps what is already
122 * stored, and each field only moves forward to a real new value. Clearing the
123 * shared key is intentionally not possible from this screen, because doing so
124 * would silently break AI in every other Fluent plugin on the site.
125 */
126 $existing = $this->getSavedCredentials();
127 $existingKey = (string) Arr::get($existing, 'api_key', '');
128 $existingProvider = (string) Arr::get($existing, 'provider', '');
129 $existingModel = (string) Arr::get($existing, 'model', '');
130
131 if ($provider && $provider !== $existingProvider && $provider !== 'wordpress'
132 && (!$apiKey || strpos($apiKey, '****') === 0)
133 ) {
134 return new \WP_Error('missing_api_key', __('Please enter an API key for the selected provider.', 'fluent-boards'));
135 }
136
137 // Only a real, non-masked value replaces the stored key.
138 $plainApiKey = $existingKey;
139 if ($apiKey !== '' && strpos($apiKey, '****') !== 0) {
140 $plainApiKey = $apiKey;
141 }
142
143 // A blank submission keeps the stored provider/model rather than blanking them.
144 if (!$provider) {
145 $provider = $existingProvider;
146 }
147 // A stored model only carries over when the provider is unchanged; another
148 // provider's model would otherwise be saved against the new provider.
149 if (!$model) {
150 $model = ($provider === $existingProvider && $existingModel) ? $existingModel : 'auto';
151 }
152
153 // Never persist a model the selected provider does not support, otherwise
154 // every later generation resolves it as-is and keeps calling the provider
155 // with an invalid model.
156 if ($provider === 'openrouter') {
157 if (!$model || $model === 'auto') {
158 return new \WP_Error('invalid_model', __('Please select an OpenRouter model.', 'fluent-boards'));
159 }
160 } elseif ($provider && !in_array($model, Arr::get($this->providerModels, $provider, []), true)) {
161 return new \WP_Error('invalid_model', __('Invalid AI model selected for this provider.', 'fluent-boards'));
162 }
163
164 $credentials = [
165 'provider' => $provider,
166 'model' => $model,
167 'api_key' => $plainApiKey,
168 // Keep whichever Fluent plugin first created the shared credentials.
169 'created_by' => sanitize_text_field(Arr::get($existing, 'created_by', '')) ?: 'fluent_boards',
170 ];
171
172 // Final guard: never write a credential set that would wipe an existing key.
173 if ($existingKey !== '' && $credentials['api_key'] === '') {
174 $credentials['api_key'] = $existingKey;
175 }
176
177 // Only touch the shared option when something actually changed.
178 if ($credentials['provider'] !== $existingProvider
179 || $credentials['model'] !== $existingModel
180 || $credentials['api_key'] !== $existingKey
181 ) {
182 update_option($this->credentialsOptionKey, $credentials, false);
183 }
184 fluent_boards_update_option($this->settingsOptionKey, [
185 'is_enabled' => $isEnabled,
186 'custom_prompt' => $customPrompt,
187 ]);
188
189 return ['message' => __('AI configuration saved successfully.', 'fluent-boards')];
190 }
191
192 /**
193 * Model dropdown options for a provider.
194 *
195 * @param string $provider
196 * @param string $apiKey only used by OpenRouter; a masked value falls back to the stored key
197 * @return array|\WP_Error
198 */
199 public function getModelOptions($provider, $apiKey = '')
200 {
201 $provider = $this->normalizeProvider($provider);
202
203 if (!$provider || !in_array($provider, array_keys($this->providerModels), true)) {
204 return new \WP_Error('invalid_provider', __('Invalid AI provider selected.', 'fluent-boards'));
205 }
206
207 if ($provider === 'openrouter') {
208 if (!$apiKey || strpos($apiKey, '****') === 0) {
209 $credentials = $this->getSavedCredentials();
210 $apiKey = $credentials['provider'] === $provider ? $credentials['api_key'] : '';
211 }
212
213 if (!$apiKey) {
214 return new \WP_Error('missing_api_key', __('Please enter an OpenRouter API key first.', 'fluent-boards'));
215 }
216
217 return $this->getOpenRouterModels($apiKey);
218 }
219
220 $models = [];
221 foreach (Arr::get($this->providerModels, $provider, []) as $model) {
222 $models[] = [
223 'value' => $model,
224 'label' => $model === 'auto' ? __('Auto', 'fluent-boards') : $model,
225 ];
226 }
227
228 return $models;
229 }
230
231 /**
232 * Round-trip a small probe prompt to validate credentials.
233 *
234 * @param array $data provider/model/api_key
235 * @return true|\WP_Error
236 */
237 public function testConnection($data)
238 {
239 $provider = $this->normalizeProvider(sanitize_text_field(Arr::get($data, 'provider', '')));
240 $model = sanitize_text_field(Arr::get($data, 'model', 'auto'));
241 $apiKey = sanitize_text_field(Arr::get($data, 'api_key', ''));
242
243 if (!$provider || !$model) {
244 return new \WP_Error('missing_config', __('Please select a provider and model first.', 'fluent-boards'));
245 }
246
247 if (!in_array($provider, array_keys($this->providerModels), true)) {
248 return new \WP_Error('invalid_provider', __('Invalid AI provider selected.', 'fluent-boards'));
249 }
250
251 // Use the stored key when the field still shows the masked value.
252 if (!$apiKey || strpos($apiKey, '****') === 0) {
253 $credentials = $this->getSavedCredentials();
254 $apiKey = $credentials['provider'] === $provider ? $credentials['api_key'] : '';
255 }
256
257 if ($provider !== 'wordpress' && !$apiKey) {
258 return new \WP_Error('missing_api_key', __('Please enter an API key.', 'fluent-boards'));
259 }
260
261 $resolvedModel = $this->resolveModels($provider, $model);
262 if (!$resolvedModel) {
263 return new \WP_Error('missing_model', __('AI model is not configured. Please set it in Settings.', 'fluent-boards'));
264 }
265
266 $result = $this->callProviderApi($provider, $resolvedModel, $apiKey, 'Say "Connection successful" in exactly two words.', '', 15);
267
268 if (is_wp_error($result)) {
269 return $result;
270 }
271
272 return true;
273 }
274
275 /**
276 * Generate/transform text for the task description editor.
277 *
278 * @param string $action one of $validActions
279 * @param string $content selected text or existing description
280 * @param string $tone optional tone hint
281 * @param string $prompt user prompt for "write"/"custom" actions
282 * @param array $context optional task context (title, board name)
283 * @return string|\WP_Error markdown
284 */
285 public function generate($action, $content, $tone = '', $prompt = '', $context = [])
286 {
287 $action = sanitize_text_field($action);
288 // Bound the payload before it reaches the blocking provider request.
289 $content = $this->truncate((string) $content, self::MAX_CONTENT_CHARS);
290 $prompt = $this->truncate(sanitize_textarea_field($prompt), self::MAX_PROMPT_CHARS);
291
292 if (!in_array($action, $this->validActions, true)) {
293 return new \WP_Error('invalid_action', __('Invalid action specified.', 'fluent-boards'));
294 }
295
296 $needsPromptActions = ['write', 'custom'];
297 if (in_array($action, $needsPromptActions, true)) {
298 if (empty($prompt) && empty($content)) {
299 return new \WP_Error('no_input', __('Please provide a prompt or select some text.', 'fluent-boards'));
300 }
301 } elseif (empty($content)) {
302 return new \WP_Error('no_content', __('No content provided to process.', 'fluent-boards'));
303 }
304
305 $settings = $this->getSavedSettings();
306
307 $config = $this->validateGenerationConfig($settings);
308 if (is_wp_error($config)) {
309 return $config;
310 }
311
312 $userPrompt = $this->buildUserPrompt($action, $content, $prompt, $context);
313 $systemPrompt = $this->getSystemPrompt($tone, $settings);
314
315 $result = $this->callProviderApi($config['provider'], $config['model'], $config['api_key'], $userPrompt, $systemPrompt, 30);
316
317 if (is_wp_error($result)) {
318 return $result;
319 }
320
321 return trim((string) $result);
322 }
323
324 /* -------------------------------------------------------------------------
325 * Task intelligence (task-level actions)
326 * ---------------------------------------------------------------------- */
327
328 /**
329 * Summarize a task (title + description + comment thread) into markdown.
330 *
331 * @param array $context task_title, board_title, description, comments
332 * @return string|\WP_Error
333 */
334 public function taskSummary($context)
335 {
336 $system = 'You are a project-management assistant. Summarize the task below for a teammate who needs to get up to speed quickly. '
337 . 'Use only the supplied information — do not invent details. '
338 . '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. '
339 . 'No preamble, no code fences.';
340
341 $result = $this->runGeneration($system, "Summarize this task.\n\n" . $this->taskContextText($context), 45);
342 if (is_wp_error($result)) {
343 return $result;
344 }
345
346 return trim((string) $result);
347 }
348
349 /**
350 * Propose a list of subtask titles from the task content.
351 *
352 * @param array $context task_title, board_title, description
353 * @return array|\WP_Error list of sanitized title strings
354 */
355 public function taskSubtasks($context)
356 {
357 $system = 'You break a task down into clear, actionable subtasks. '
358 . 'Return ONLY a JSON array of short subtask title strings (max 10), ordered logically. '
359 . 'Each title is a concise action, no numbering, no extra prose, no code fences.';
360
361 $result = $this->runGeneration($system, "Task to break down:\n\n" . $this->taskContextText($context), 45);
362 if (is_wp_error($result)) {
363 return $result;
364 }
365
366 return $this->parseJsonList($result);
367 }
368
369 /**
370 * Suggest labels (from an allowed list) and a priority for the task.
371 *
372 * @param array $context task content
373 * @param array $allowedLabels label names the board offers
374 * @param array $allowedPriorities allowed priority keys
375 * @return array|\WP_Error ['labels' => [...names], 'priority' => key]
376 */
377 public function taskSuggestions($context, $allowedLabels, $allowedPriorities)
378 {
379 $allowedLabels = array_values(array_filter(array_map('strval', (array) $allowedLabels)));
380 $allowedPriorities = array_values(array_filter(array_map('strval', (array) $allowedPriorities)));
381
382 $labelList = $allowedLabels ? implode(', ', $allowedLabels) : '(none configured)';
383 $priorityList = implode(', ', $allowedPriorities);
384
385 $system = 'You classify project tasks. '
386 . 'Return ONLY JSON of the shape {"labels":["..."],"priority":"..."}. '
387 . 'Choose labels ONLY from the provided label list (return an empty array if none fit). '
388 . 'Choose priority ONLY from the provided priority list. No prose, no code fences.';
389
390 $user = 'Available labels: ' . $labelList . "\n"
391 . 'Available priorities: ' . $priorityList . "\n\n"
392 . "Task:\n" . $this->taskContextText($context);
393
394 $result = $this->runGeneration($system, $user, 30);
395 if (is_wp_error($result)) {
396 return $result;
397 }
398
399 return $this->parseSuggestions($result, $allowedLabels, $allowedPriorities);
400 }
401
402 /**
403 * Validate config + call the provider with a system/user prompt pair.
404 *
405 * @return string|\WP_Error
406 */
407 private function runGeneration($systemPrompt, $userPrompt, $timeout = 30)
408 {
409 $config = $this->validateGenerationConfig($this->getSavedSettings());
410 if (is_wp_error($config)) {
411 return $config;
412 }
413
414 return $this->callProviderApi($config['provider'], $config['model'], $config['api_key'], $userPrompt, $systemPrompt, $timeout);
415 }
416
417 /**
418 * Build the task context, keeping description + comments inside one shared
419 * character budget so a long task can never produce an unbounded prompt.
420 */
421 private function taskContextText($context)
422 {
423 $parts = [];
424 if ($title = trim((string) Arr::get($context, 'task_title', ''))) {
425 $parts[] = 'Title: ' . $title;
426 }
427 if ($board = trim((string) Arr::get($context, 'board_title', ''))) {
428 $parts[] = 'Board: ' . $board;
429 }
430
431 $remaining = max(0, self::MAX_CONTEXT_CHARS - $this->length(implode("\n\n", $parts)));
432
433 // The description gets up to 60% of the budget; comments take what is left.
434 if (($description = trim((string) Arr::get($context, 'description', ''))) && $remaining > 0) {
435 $description = $this->truncate($description, (int) min($remaining, self::MAX_CONTEXT_CHARS * 0.6));
436 $parts[] = "Description:\n" . $description;
437 $remaining = max(0, $remaining - $this->length($description));
438 }
439
440 if (($comments = trim((string) Arr::get($context, 'comments', ''))) && $remaining > 0) {
441 // Trim from the start so the most recent comments survive.
442 $parts[] = "Comments:\n" . $this->truncateStart($comments, $remaining);
443 }
444
445 return implode("\n\n", $parts);
446 }
447
448 private function length($text)
449 {
450 return function_exists('mb_strlen') ? mb_strlen((string) $text) : strlen((string) $text);
451 }
452
453 /**
454 * Keep the beginning of the text, dropping the overflow.
455 */
456 private function truncate($text, $limit)
457 {
458 $text = (string) $text;
459 if ($limit <= 0 || $this->length($text) <= $limit) {
460 return $text;
461 }
462
463 $cut = function_exists('mb_substr') ? mb_substr($text, 0, $limit) : substr($text, 0, $limit);
464
465 return $cut . "\n… [truncated]";
466 }
467
468 /**
469 * Keep the end of the text (most recent content), dropping the older overflow.
470 */
471 private function truncateStart($text, $limit)
472 {
473 $text = (string) $text;
474 if ($limit <= 0 || $this->length($text) <= $limit) {
475 return $text;
476 }
477
478 $cut = function_exists('mb_substr') ? mb_substr($text, -$limit) : substr($text, -$limit);
479
480 return "… [older content truncated]\n" . $cut;
481 }
482
483 private function parseJsonList($result)
484 {
485 $decoded = json_decode(trim((string) $result), true);
486 if (!is_array($decoded) && preg_match('/\[.*\]/s', (string) $result, $m)) {
487 $decoded = json_decode($m[0], true);
488 }
489 if (!is_array($decoded)) {
490 return [];
491 }
492
493 $items = [];
494 foreach ($decoded as $item) {
495 if (is_string($item)) {
496 $title = $item;
497 } elseif (is_array($item)) {
498 $title = Arr::get($item, 'title', '');
499 } else {
500 continue;
501 }
502 $title = sanitize_text_field(trim((string) $title));
503 if ($title !== '') {
504 $items[] = $title;
505 }
506 }
507
508 return array_slice($items, 0, 15);
509 }
510
511 private function parseSuggestions($result, $allowedLabels, $allowedPriorities)
512 {
513 $decoded = json_decode(trim((string) $result), true);
514 if (!is_array($decoded) && preg_match('/\{.*\}/s', (string) $result, $m)) {
515 $decoded = json_decode($m[0], true);
516 }
517 if (!is_array($decoded)) {
518 $decoded = [];
519 }
520
521 $labels = [];
522 foreach ((array) Arr::get($decoded, 'labels', []) as $label) {
523 $label = trim((string) $label);
524 foreach ($allowedLabels as $allowed) {
525 if (strtolower($allowed) === strtolower($label)) {
526 $labels[] = $allowed;
527 break;
528 }
529 }
530 }
531
532 $priority = sanitize_text_field((string) Arr::get($decoded, 'priority', ''));
533 if (!in_array($priority, $allowedPriorities, true)) {
534 $priority = '';
535 }
536
537 return [
538 'labels' => array_values(array_unique($labels)),
539 'priority' => $priority,
540 ];
541 }
542
543 /**
544 * @return array{provider:string,model:string[],api_key:string}|\WP_Error
545 */
546 private function validateGenerationConfig($settings)
547 {
548 if (Arr::get($settings, 'is_enabled') !== 'yes') {
549 return new \WP_Error('ai_disabled', __('AI features are not enabled. Please configure AI in Settings.', 'fluent-boards'));
550 }
551
552 $provider = Arr::get($settings, 'provider', '');
553 $apiKey = Arr::get($settings, 'api_key', '');
554 $model = Arr::get($settings, 'model', '');
555
556 if (!$provider || !in_array($provider, array_keys($this->providerModels), true)) {
557 return new \WP_Error('missing_provider', __('AI provider is not configured. Please set it in Settings.', 'fluent-boards'));
558 }
559
560 if ($provider !== 'wordpress' && !$apiKey) {
561 return new \WP_Error('missing_api_key', __('AI API key is not configured. Please add it in Settings.', 'fluent-boards'));
562 }
563
564 $resolvedModel = $this->resolveModels($provider, $model);
565 if (!$resolvedModel) {
566 return new \WP_Error('missing_model', __('AI model is not configured. Please set it in Settings.', 'fluent-boards'));
567 }
568
569 return [
570 'provider' => $provider,
571 'model' => $resolvedModel,
572 'api_key' => $apiKey,
573 ];
574 }
575
576 private function getSystemPrompt($tone = '', $settings = [])
577 {
578 $prompt = 'You are a writing assistant embedded in a project-management task editor. '
579 . 'You help write and improve task descriptions. Write clearly and concisely for a work context. '
580 . 'Return ONLY the resulting text in GitHub-flavored Markdown. '
581 . 'Do not add explanations, preamble, or wrap the answer in code fences. '
582 . 'Use headings, bullet lists, and checklists (- [ ]) where they make the description clearer. '
583 . 'Keep any existing Markdown structure intact unless asked to change it.';
584
585 if ($tone) {
586 $prompt .= ' Use a ' . strtolower(sanitize_text_field($tone)) . ' tone.';
587 }
588
589 $custom = trim((string) Arr::get($settings, 'custom_prompt', ''));
590 if ($custom) {
591 $prompt .= "\n\nAdditional instructions: " . $custom;
592 }
593
594 return $prompt;
595 }
596
597 private function buildUserPrompt($action, $content, $prompt, $context = [])
598 {
599 $contextText = $this->buildContextText($context);
600
601 switch ($action) {
602 case 'write':
603 $base = "Write a task description based on this instruction:\n\n" . ($prompt ?: $content);
604 if ($content && $prompt) {
605 $base .= "\n\nExisting text for reference:\n" . $content;
606 }
607 return $contextText . $base;
608 case 'improve':
609 return $contextText . "Improve the following task description. Make it clearer and better structured while keeping the meaning:\n\n" . $content;
610 case 'summarize':
611 return $contextText . "Summarize the following task description into a short, clear TL;DR:\n\n" . $content;
612 case 'shorten':
613 return $contextText . "Make the following task description shorter and more concise:\n\n" . $content;
614 case 'expand':
615 return $contextText . "Expand the following task description with more helpful detail:\n\n" . $content;
616 case 'format':
617 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;
618 case 'fix_grammar':
619 return $contextText . "Fix grammar, spelling, and punctuation in the following text. Keep the wording and Markdown otherwise unchanged:\n\n" . $content;
620 case 'custom':
621 return $contextText . $prompt . ($content ? "\n\nText:\n" . $content : '');
622 default:
623 return $content;
624 }
625 }
626
627 private function buildContextText($context)
628 {
629 if (!is_array($context) || empty($context)) {
630 return '';
631 }
632
633 $lines = [];
634 if ($title = sanitize_text_field(Arr::get($context, 'task_title', ''))) {
635 $lines[] = 'Task title: ' . $title;
636 }
637 if ($board = sanitize_text_field(Arr::get($context, 'board_title', ''))) {
638 $lines[] = 'Board: ' . $board;
639 }
640
641 if (!$lines) {
642 return '';
643 }
644
645 return "Context for the task you are helping with:\n" . implode("\n", $lines) . "\n\n";
646 }
647
648 /* -------------------------------------------------------------------------
649 * Provider dispatch
650 * ---------------------------------------------------------------------- */
651
652 /**
653 * @param string|string[] $models candidates in order; the next one is tried
654 * only when the provider rejects the model
655 */
656 private function callProviderApi($provider, $models, $apiKey, $userPrompt, $systemPrompt = '', $timeout = 30)
657 {
658 $result = null;
659 foreach ((array) $models as $model) {
660 $result = $this->callProviderModel($provider, $model, $apiKey, $userPrompt, $systemPrompt, $timeout);
661 if (!$this->isModelUnavailableError($provider, $model, $result)) {
662 return $result;
663 }
664 }
665
666 return $result;
667 }
668
669 /** Fail closed: generic 404s and all auth, quota, permission and parameter errors do not retry. */
670 private function isModelUnavailableError($provider, $model, $error)
671 {
672 if (!is_wp_error($error)) {
673 return false;
674 }
675 $data = (array) $error->get_error_data();
676 if ((int) Arr::get($data, 'status') !== 404) {
677 return false;
678 }
679 $code = Arr::get($data, 'provider_error');
680 if ($provider === 'open_ai') {
681 return $code === 'model_not_found';
682 }
683 // Claude/Gemini use resource-wide codes, so also require the requested model ID.
684 $expected = ['claude' => 'not_found_error', 'gemini' => 'NOT_FOUND'];
685 return isset($expected[$provider]) && $code === $expected[$provider]
686 && strpos($error->get_error_message(), $model) !== false;
687 }
688
689 private function callProviderModel($provider, $model, $apiKey, $userPrompt, $systemPrompt, $timeout)
690 {
691 switch ($provider) {
692 case 'open_ai':
693 return $this->callOpenAi($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
694 case 'claude':
695 return $this->callClaude($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
696 case 'gemini':
697 return $this->callGemini($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
698 case 'openrouter':
699 return $this->callOpenRouter($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
700 case 'wordpress':
701 return $this->callWordPress($model, $userPrompt, $systemPrompt, $timeout);
702 default:
703 return new \WP_Error('invalid_provider', __('Invalid AI provider.', 'fluent-boards'));
704 }
705 }
706
707 private function callWordPress($model, $userPrompt, $systemPrompt, $timeout)
708 {
709 $filtered = apply_filters('fluent_boards/wordpress_ai_generate', null, $userPrompt, $systemPrompt, $model, $timeout);
710 if ($filtered !== null) {
711 return $filtered;
712 }
713
714 if (function_exists('wp_ai_client_prompt')) {
715 $prompt = wp_ai_client_prompt($userPrompt);
716 if ($systemPrompt) {
717 $prompt->using_system_instruction($systemPrompt);
718 }
719 if ($prompt->is_supported_for_text_generation()) {
720 $result = $prompt->generate_text();
721 if (is_wp_error($result)) {
722 return $result;
723 }
724 if (empty($result)) {
725 return new \WP_Error('empty_response', __('No content generated by WordPress AI client. Please try again.', 'fluent-boards'));
726 }
727 return $result;
728 }
729 return new \WP_Error('not_supported', __('WordPress AI client is not configured or supported on this site.', 'fluent-boards'));
730 }
731
732 return new \WP_Error(
733 'wordpress_ai_not_supported',
734 __('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')
735 );
736 }
737
738 private function callOpenAi($model, $apiKey, $userPrompt, $systemPrompt, $timeout)
739 {
740 $messages = [];
741 if ($systemPrompt) {
742 $messages[] = ['role' => 'system', 'content' => $systemPrompt];
743 }
744 $messages[] = ['role' => 'user', 'content' => $userPrompt];
745
746 $data = [
747 'model' => $model,
748 'messages' => $messages,
749 'max_completion_tokens' => 2048,
750 ];
751 // Astra and 6.1 Sol require reasoning; reserve room for reasoning and text.
752 if (in_array($model, ['gpt-6-astra', 'gpt-6.1-sol'], true)) {
753 $data['reasoning_effort'] = 'low';
754 $data['max_completion_tokens'] = 8192;
755 } elseif ($model === 'gpt-6-luna') {
756 $data['reasoning_effort'] = 'none';
757 }
758
759 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
760 'timeout' => $timeout,
761 'headers' => [
762 'Authorization' => 'Bearer ' . $apiKey,
763 'Content-Type' => 'application/json',
764 ],
765 'body' => wp_json_encode($data),
766 ]);
767
768 if (is_wp_error($response)) {
769 /* translators: %s is the error message returned by the AI provider */
770 return new \WP_Error('api_error', sprintf(__('Failed to connect to OpenAI: %s', 'fluent-boards'), $response->get_error_message()));
771 }
772
773 $code = wp_remote_retrieve_response_code($response);
774 $body = json_decode(wp_remote_retrieve_body($response), true);
775
776 if ($code !== 200) {
777 return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from OpenAI.', 'fluent-boards')), ['status' => $code, 'provider_error' => Arr::get($body, 'error.code')]);
778 }
779
780 $content = Arr::get($body, 'choices.0.message.content', '');
781 if (empty($content)) {
782 return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
783 }
784
785 return $content;
786 }
787
788 private function callClaude($model, $apiKey, $userPrompt, $systemPrompt, $timeout)
789 {
790 $data = [
791 'model' => $model,
792 'max_tokens' => 2048,
793 'messages' => [
794 ['role' => 'user', 'content' => $userPrompt],
795 ],
796 ];
797
798 // These models think by default. Keep short Boards writing requests bounded.
799 if ($model === 'claude-sonnet-5-5') {
800 $data['thinking'] = ['type' => 'between_tools'];
801 $data['output_config'] = ['effort' => 'low'];
802 } elseif ($model === 'claude-haiku-5-5') {
803 $data['thinking'] = ['type' => 'disabled'];
804 $data['output_config'] = ['effort' => 'low'];
805 } elseif (in_array($model, ['claude-opus-5-5', 'claude-fable-5-1'], true)) {
806 $data['output_config'] = ['effort' => 'low'];
807 $data['max_tokens'] = 8192;
808 }
809
810 if ($systemPrompt) {
811 $data['system'] = $systemPrompt;
812 }
813
814 $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
815 'timeout' => $timeout,
816 'headers' => [
817 'x-api-key' => $apiKey,
818 'anthropic-version' => '2023-06-01',
819 'Content-Type' => 'application/json',
820 ],
821 'body' => wp_json_encode($data),
822 ]);
823
824 if (is_wp_error($response)) {
825 /* translators: %s is the error message returned by the AI provider */
826 return new \WP_Error('api_error', sprintf(__('Failed to connect to Claude: %s', 'fluent-boards'), $response->get_error_message()));
827 }
828
829 $code = wp_remote_retrieve_response_code($response);
830 $body = json_decode(wp_remote_retrieve_body($response), true);
831
832 if ($code !== 200) {
833 return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from Claude.', 'fluent-boards')), ['status' => $code, 'provider_error' => Arr::get($body, 'error.type')]);
834 }
835
836 // Thinking-enabled models can return non-text blocks before the answer.
837 $textBlocks = [];
838 foreach ((array) Arr::get($body, 'content', []) as $block) {
839 if (Arr::get($block, 'type') === 'text') {
840 $textBlocks[] = Arr::get($block, 'text', '');
841 }
842 }
843 $content = implode("\n", $textBlocks);
844 if (empty($content)) {
845 return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
846 }
847
848 return $content;
849 }
850
851 private function callGemini($model, $apiKey, $userPrompt, $systemPrompt, $timeout)
852 {
853 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent';
854
855 $data = [
856 'contents' => [
857 ['parts' => [['text' => $userPrompt]]],
858 ],
859 'generationConfig' => [
860 'maxOutputTokens' => 2048,
861 ],
862 ];
863
864 // Gemini 3.8 does not support minimal thinking; budget for low thinking plus text.
865 if ($model === 'gemini-3.8-flash') {
866 $data['generationConfig']['thinkingConfig'] = ['thinkingLevel' => 'low'];
867 $data['generationConfig']['maxOutputTokens'] = 8192;
868 }
869
870 if ($systemPrompt) {
871 $data['system_instruction'] = ['parts' => [['text' => $systemPrompt]]];
872 }
873
874 $response = wp_remote_post($url, [
875 'timeout' => $timeout,
876 'headers' => [
877 'Content-Type' => 'application/json',
878 'x-goog-api-key' => $apiKey,
879 ],
880 'body' => wp_json_encode($data),
881 ]);
882
883 if (is_wp_error($response)) {
884 /* translators: %s is the error message returned by the AI provider */
885 return new \WP_Error('api_error', sprintf(__('Failed to connect to Gemini: %s', 'fluent-boards'), $response->get_error_message()));
886 }
887
888 $code = wp_remote_retrieve_response_code($response);
889 $body = json_decode(wp_remote_retrieve_body($response), true);
890
891 if ($code !== 200) {
892 return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from Gemini.', 'fluent-boards')), ['status' => $code, 'provider_error' => Arr::get($body, 'error.status')]);
893 }
894
895 $content = Arr::get($body, 'candidates.0.content.parts.0.text', '');
896 if (empty($content)) {
897 return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
898 }
899
900 return $content;
901 }
902
903 private function callOpenRouter($model, $apiKey, $userPrompt, $systemPrompt, $timeout)
904 {
905 $messages = [];
906 if ($systemPrompt) {
907 $messages[] = ['role' => 'system', 'content' => $systemPrompt];
908 }
909 $messages[] = ['role' => 'user', 'content' => $userPrompt];
910
911 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', [
912 'timeout' => $timeout,
913 'headers' => [
914 'Authorization' => 'Bearer ' . $apiKey,
915 'Content-Type' => 'application/json',
916 ],
917 'body' => wp_json_encode([
918 'model' => $model,
919 'messages' => $messages,
920 'max_completion_tokens' => 2048,
921 ]),
922 ]);
923
924 if (is_wp_error($response)) {
925 /* translators: %1$s: AI provider name, %2$s: error message returned by the provider */
926 return new \WP_Error('api_error', sprintf(__('Failed to connect to %1$s: %2$s', 'fluent-boards'), 'OpenRouter', $response->get_error_message()));
927 }
928
929 $code = wp_remote_retrieve_response_code($response);
930 $body = json_decode(wp_remote_retrieve_body($response), true);
931 if ($code !== 200) {
932 return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from OpenRouter.', 'fluent-boards')), ['status' => $code]);
933 }
934
935 $content = Arr::get($body, 'choices.0.message.content', '');
936 if (empty($content)) {
937 return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
938 }
939
940 return $content;
941 }
942
943 private function getOpenRouterModels($apiKey)
944 {
945 $response = wp_remote_get('https://openrouter.ai/api/v1/models?output_modalities=text', [
946 'timeout' => 15,
947 'headers' => ['Authorization' => 'Bearer ' . $apiKey],
948 ]);
949
950 if (is_wp_error($response)) {
951 /* translators: %s: error message returned by OpenRouter */
952 return new \WP_Error('api_error', sprintf(__('Failed to load OpenRouter models: %s', 'fluent-boards'), $response->get_error_message()));
953 }
954
955 $code = wp_remote_retrieve_response_code($response);
956 $body = json_decode(wp_remote_retrieve_body($response), true);
957 if ($code !== 200) {
958 return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Could not load OpenRouter models.', 'fluent-boards')));
959 }
960
961 $models = [];
962 foreach ((array) Arr::get($body, 'data', []) as $model) {
963 $id = sanitize_text_field(Arr::get($model, 'id', ''));
964 if (!$id) {
965 continue;
966 }
967
968 $models[] = [
969 'value' => $id,
970 'label' => sanitize_text_field(Arr::get($model, 'name', $id)),
971 ];
972 }
973
974 if (!$models) {
975 return new \WP_Error('empty_models', __('OpenRouter returned no text models for this API key.', 'fluent-boards'));
976 }
977
978 return $models;
979 }
980
981 /* -------------------------------------------------------------------------
982 * Storage helpers
983 * ---------------------------------------------------------------------- */
984
985 private function getSavedSettings()
986 {
987 $defaults = [
988 'is_enabled' => 'no',
989 'provider' => '',
990 'api_key' => '',
991 'model' => 'auto',
992 'custom_prompt' => '',
993 ];
994
995 $settings = array_merge($this->getSavedPreferences(), $this->getSavedCredentials());
996
997 return wp_parse_args($settings, $defaults);
998 }
999
1000 private function getSavedCredentials()
1001 {
1002 $credentials = get_option($this->credentialsOptionKey, []);
1003 if (!is_array($credentials)) {
1004 $credentials = [];
1005 }
1006
1007 $provider = $this->normalizeProvider(Arr::get($credentials, 'provider', ''));
1008 $model = sanitize_text_field(Arr::get($credentials, 'model', 'auto')) ?: 'auto';
1009
1010 // A retired model (removed from the list, or saved by another Fluent plugin
1011 // that still offers it) falls back to Auto instead of failing every request.
1012 $knownModels = Arr::get($this->providerModels, $provider, []);
1013 if ($knownModels && !in_array($model, $knownModels, true)) {
1014 $model = 'auto';
1015 }
1016
1017 return [
1018 'provider' => $provider,
1019 'model' => $model,
1020 'api_key' => sanitize_text_field(Arr::get($credentials, 'api_key', '')),
1021 'created_by' => sanitize_text_field(Arr::get($credentials, 'created_by', '')),
1022 ];
1023 }
1024
1025 private function getSavedPreferences()
1026 {
1027 $preferences = fluent_boards_get_option($this->settingsOptionKey, []);
1028 if (!is_array($preferences) || empty($preferences)) {
1029 // Fall back to the pre-release key so an existing toggle is not lost.
1030 $legacy = fluent_boards_get_option($this->legacySettingsOptionKey, []);
1031 $preferences = is_array($legacy) ? $legacy : [];
1032 }
1033
1034 return [
1035 'is_enabled' => sanitize_text_field(Arr::get($preferences, 'is_enabled', 'no')) === 'yes' ? 'yes' : 'no',
1036 'custom_prompt' => sanitize_textarea_field(Arr::get($preferences, 'custom_prompt', '')),
1037 ];
1038 }
1039
1040 private function normalizeProvider($provider)
1041 {
1042 $provider = sanitize_key($provider);
1043 return $provider === 'openai' ? 'open_ai' : $provider;
1044 }
1045
1046 /**
1047 * Candidate models to try, in order. An explicit choice is used as-is.
1048 *
1049 * @return string[]
1050 */
1051 private function resolveModels($provider, $model)
1052 {
1053 $model = $model ?: 'auto';
1054
1055 if ($model !== 'auto') {
1056 return [$model];
1057 }
1058
1059 return Arr::get($this->autoProviderModels, $provider, []);
1060 }
1061
1062 /**
1063 * Whether AI writing is enabled and configured (used to gate the editor UI).
1064 */
1065 public function isReady()
1066 {
1067 $settings = $this->getSavedSettings();
1068
1069 if (Arr::get($settings, 'is_enabled') !== 'yes') {
1070 return false;
1071 }
1072
1073 $provider = Arr::get($settings, 'provider', '');
1074 if (!$provider) {
1075 return false;
1076 }
1077
1078 if ($provider !== 'wordpress' && !Arr::get($settings, 'api_key', '')) {
1079 return false;
1080 }
1081
1082 return true;
1083 }
1084 }
1085