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 / Http / Controllers / AiController.php

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

244 lines 8.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\Http\Controllers;
4
5 use FluentBoards\App\App;
6 use FluentBoards\App\Models\Board;
7 use FluentBoards\App\Models\Comment;
8 use FluentBoards\App\Models\Task;
9 use FluentBoards\App\Services\AiService;
10 use FluentBoards\App\Services\LabelService;
11 use FluentBoards\App\Services\TaskService;
12 use FluentBoards\Framework\Http\Request\Request;
13 use FluentBoards\Framework\Support\Arr;
14
15 /**
16 * AI writing assistant endpoints for the task description editor.
17 *
18 * Settings routes are admin-only; `generate` is available to any authenticated
19 * Fluent Boards user (see AiPolicy).
20 */
21 class AiController extends Controller
22 {
23 /** Caps on the comment thread pulled into a task summary prompt. */
24 const MAX_SUMMARY_COMMENTS = 30;
25 const MAX_COMMENT_CHARS = 1000;
26
27 private AiService $aiService;
28
29 public function __construct(AiService $aiService)
30 {
31 parent::__construct();
32 $this->aiService = $aiService;
33 }
34
35 public function getSettings(Request $request)
36 {
37 return $this->sendSuccess([
38 'settings' => $this->aiService->getDisplaySettings(),
39 'has_wordpress_ai' => $this->aiService->hasWordPressAi(),
40 'connectors_url' => admin_url('options-connectors.php'),
41 ]);
42 }
43
44 public function saveSettings(Request $request)
45 {
46 $result = $this->aiService->saveSettings($request->get('settings', []));
47
48 if (is_wp_error($result)) {
49 return $this->sendError(['message' => $result->get_error_message()], 422);
50 }
51
52 return $this->sendSuccess($result);
53 }
54
55 public function getModels(Request $request)
56 {
57 $data = $request->get('settings', []);
58 $provider = sanitize_text_field(Arr::get($data, 'provider', ''));
59 $apiKey = sanitize_text_field(Arr::get($data, 'api_key', ''));
60 $models = $this->aiService->getModelOptions($provider, $apiKey);
61
62 if (is_wp_error($models)) {
63 return $this->sendError(['message' => $models->get_error_message()], 422);
64 }
65
66 return $this->sendSuccess(['models' => $models]);
67 }
68
69 public function testConnection(Request $request)
70 {
71 $result = $this->aiService->testConnection($request->get('settings', []));
72
73 if (is_wp_error($result)) {
74 return $this->sendError(['message' => $result->get_error_message()], 422);
75 }
76
77 return $this->sendSuccess([
78 'message' => __('Connection successful! Your API key is valid.', 'fluent-boards'),
79 ]);
80 }
81
82 public function generate(Request $request)
83 {
84 $context = $request->get('context', []);
85 if (!is_array($context)) {
86 $context = [];
87 }
88
89 $result = $this->aiService->generate(
90 $request->get('action', ''),
91 $request->get('content', ''),
92 sanitize_text_field($request->get('tone', '')),
93 $request->get('prompt', ''),
94 $context
95 );
96
97 if (is_wp_error($result)) {
98 return $this->sendError(['message' => $result->get_error_message()], 422);
99 }
100
101 return $this->sendSuccess(['content' => $result]);
102 }
103
104 /**
105 * Task-level AI actions (board-scoped via SingleBoardPolicy):
106 * summarize | subtasks | suggestions. Returns structured data the frontend
107 * previews and applies through existing task endpoints.
108 */
109 public function taskAssist(Request $request, $board_id, $task_id)
110 {
111 $action = sanitize_text_field($request->get('action', ''));
112
113 $task = Task::where('id', $task_id)->where('board_id', $board_id)->first();
114 if (!$task) {
115 return $this->sendError(['message' => __('Task not found.', 'fluent-boards')], 404);
116 }
117
118 $board = Board::find($board_id);
119 $context = [
120 'task_title' => $task->title,
121 'board_title' => $board ? $board->title : '',
122 'description' => (string) $task->description,
123 ];
124
125 if ($action === 'summarize') {
126 $context['comments'] = $this->collectTaskComments($task_id);
127 $result = $this->aiService->taskSummary($context);
128 if (is_wp_error($result)) {
129 return $this->sendError(['message' => $result->get_error_message()], 422);
130 }
131 return $this->sendSuccess(['type' => 'summary', 'content' => $result]);
132 }
133
134 if ($action === 'subtasks') {
135 $result = $this->aiService->taskSubtasks($context);
136 if (is_wp_error($result)) {
137 return $this->sendError(['message' => $result->get_error_message()], 422);
138 }
139 return $this->sendSuccess(['type' => 'subtasks', 'items' => $result]);
140 }
141
142 if ($action === 'suggestions') {
143 $labels = array_values(array_filter(array_map('sanitize_text_field', (array) $request->get('labels', []))));
144 $result = $this->aiService->taskSuggestions($context, $labels, ['urgent', 'high', 'medium', 'low']);
145 if (is_wp_error($result)) {
146 return $this->sendError(['message' => $result->get_error_message()], 422);
147 }
148 return $this->sendSuccess([
149 'type' => 'suggestions',
150 'labels' => $result['labels'],
151 'priority' => $result['priority'],
152 ]);
153 }
154
155 return $this->sendError(['message' => __('Invalid AI action.', 'fluent-boards')], 422);
156 }
157
158 /**
159 * Apply suggested labels and priority in one transactional request, returning
160 * the committed state so the frontend refreshes once.
161 */
162 public function applySuggestions(Request $request, $board_id, $task_id)
163 {
164 $labelIds = array_values(array_unique(array_filter(array_map(
165 'intval',
166 (array) $request->get('label_ids', [])
167 ))));
168 $priority = sanitize_text_field($request->get('priority', ''));
169
170 if ($priority && !in_array($priority, ['urgent', 'high', 'medium', 'low'], true)) {
171 return $this->sendError(['message' => __('Invalid priority.', 'fluent-boards')], 422);
172 }
173
174 if (!$labelIds && !$priority) {
175 return $this->sendError(['message' => __('Nothing to apply.', 'fluent-boards')], 422);
176 }
177
178 $task = Task::where('id', $task_id)->where('board_id', $board_id)->first();
179 if (!$task) {
180 return $this->sendError(['message' => __('Task not found.', 'fluent-boards')], 404);
181 }
182
183 $db = App::getInstance('db');
184 $db->beginTransaction();
185
186 try {
187 $labelService = new LabelService();
188 foreach ($labelIds as $labelId) {
189 // syncWithoutDetaching keeps this idempotent on retry.
190 $labelService->createLabelForTask([
191 'task_id' => $task->id,
192 'board_term_id' => $labelId,
193 ], $board_id);
194 }
195
196 if ($priority) {
197 (new TaskService())->updateTaskProperty('priority', $priority, $task);
198 }
199
200 $db->commit();
201 } catch (\Exception $e) {
202 $db->rollBack();
203 return $this->sendError(['message' => $e->getMessage()], 400);
204 }
205
206 $task->load('labels');
207
208 return $this->sendSuccess([
209 'task' => $task,
210 'labels' => $task->labels,
211 'priority' => $task->priority,
212 'message' => __('Suggestions applied.', 'fluent-boards'),
213 ]);
214 }
215
216 /**
217 * Most recent comments, oldest-first, with each one capped so a single long
218 * comment cannot dominate the prompt budget.
219 */
220 private function collectTaskComments($taskId)
221 {
222 $comments = Comment::where('task_id', $taskId)
223 ->where('type', 'comment')
224 ->orderBy('id', 'desc')
225 ->limit(self::MAX_SUMMARY_COMMENTS)
226 ->get();
227
228 $lines = [];
229 foreach ($comments as $comment) {
230 $text = trim(wp_strip_all_tags((string) $comment->description));
231 if ($text === '') {
232 continue;
233 }
234 if (mb_strlen($text) > self::MAX_COMMENT_CHARS) {
235 $text = mb_substr($text, 0, self::MAX_COMMENT_CHARS) . '…';
236 }
237 $lines[] = '- ' . $text;
238 }
239
240 // Re-order chronologically now that the newest have been selected.
241 return implode("\n", array_reverse($lines));
242 }
243 }
244