PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.0.12
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.0.12
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 / Http / Controllers / AiController.php

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

242 lines 8.2 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 $provider = sanitize_text_field(Arr::get($request->get('settings', []), 'provider', ''));
58 $models = $this->aiService->getModelOptions($provider);
59
60 if (is_wp_error($models)) {
61 return $this->sendError(['message' => $models->get_error_message()], 422);
62 }
63
64 return $this->sendSuccess(['models' => $models]);
65 }
66
67 public function testConnection(Request $request)
68 {
69 $result = $this->aiService->testConnection($request->get('settings', []));
70
71 if (is_wp_error($result)) {
72 return $this->sendError(['message' => $result->get_error_message()], 422);
73 }
74
75 return $this->sendSuccess([
76 'message' => __('Connection successful! Your API key is valid.', 'fluent-boards'),
77 ]);
78 }
79
80 public function generate(Request $request)
81 {
82 $context = $request->get('context', []);
83 if (!is_array($context)) {
84 $context = [];
85 }
86
87 $result = $this->aiService->generate(
88 $request->get('action', ''),
89 $request->get('content', ''),
90 sanitize_text_field($request->get('tone', '')),
91 $request->get('prompt', ''),
92 $context
93 );
94
95 if (is_wp_error($result)) {
96 return $this->sendError(['message' => $result->get_error_message()], 422);
97 }
98
99 return $this->sendSuccess(['content' => $result]);
100 }
101
102 /**
103 * Task-level AI actions (board-scoped via SingleBoardPolicy):
104 * summarize | subtasks | suggestions. Returns structured data the frontend
105 * previews and applies through existing task endpoints.
106 */
107 public function taskAssist(Request $request, $board_id, $task_id)
108 {
109 $action = sanitize_text_field($request->get('action', ''));
110
111 $task = Task::where('id', $task_id)->where('board_id', $board_id)->first();
112 if (!$task) {
113 return $this->sendError(['message' => __('Task not found.', 'fluent-boards')], 404);
114 }
115
116 $board = Board::find($board_id);
117 $context = [
118 'task_title' => $task->title,
119 'board_title' => $board ? $board->title : '',
120 'description' => (string) $task->description,
121 ];
122
123 if ($action === 'summarize') {
124 $context['comments'] = $this->collectTaskComments($task_id);
125 $result = $this->aiService->taskSummary($context);
126 if (is_wp_error($result)) {
127 return $this->sendError(['message' => $result->get_error_message()], 422);
128 }
129 return $this->sendSuccess(['type' => 'summary', 'content' => $result]);
130 }
131
132 if ($action === 'subtasks') {
133 $result = $this->aiService->taskSubtasks($context);
134 if (is_wp_error($result)) {
135 return $this->sendError(['message' => $result->get_error_message()], 422);
136 }
137 return $this->sendSuccess(['type' => 'subtasks', 'items' => $result]);
138 }
139
140 if ($action === 'suggestions') {
141 $labels = array_values(array_filter(array_map('sanitize_text_field', (array) $request->get('labels', []))));
142 $result = $this->aiService->taskSuggestions($context, $labels, ['urgent', 'high', 'medium', 'low']);
143 if (is_wp_error($result)) {
144 return $this->sendError(['message' => $result->get_error_message()], 422);
145 }
146 return $this->sendSuccess([
147 'type' => 'suggestions',
148 'labels' => $result['labels'],
149 'priority' => $result['priority'],
150 ]);
151 }
152
153 return $this->sendError(['message' => __('Invalid AI action.', 'fluent-boards')], 422);
154 }
155
156 /**
157 * Apply suggested labels and priority in one transactional request, returning
158 * the committed state so the frontend refreshes once.
159 */
160 public function applySuggestions(Request $request, $board_id, $task_id)
161 {
162 $labelIds = array_values(array_unique(array_filter(array_map(
163 'intval',
164 (array) $request->get('label_ids', [])
165 ))));
166 $priority = sanitize_text_field($request->get('priority', ''));
167
168 if ($priority && !in_array($priority, ['urgent', 'high', 'medium', 'low'], true)) {
169 return $this->sendError(['message' => __('Invalid priority.', 'fluent-boards')], 422);
170 }
171
172 if (!$labelIds && !$priority) {
173 return $this->sendError(['message' => __('Nothing to apply.', 'fluent-boards')], 422);
174 }
175
176 $task = Task::where('id', $task_id)->where('board_id', $board_id)->first();
177 if (!$task) {
178 return $this->sendError(['message' => __('Task not found.', 'fluent-boards')], 404);
179 }
180
181 $db = App::getInstance('db');
182 $db->beginTransaction();
183
184 try {
185 $labelService = new LabelService();
186 foreach ($labelIds as $labelId) {
187 // syncWithoutDetaching keeps this idempotent on retry.
188 $labelService->createLabelForTask([
189 'task_id' => $task->id,
190 'board_term_id' => $labelId,
191 ], $board_id);
192 }
193
194 if ($priority) {
195 (new TaskService())->updateTaskProperty('priority', $priority, $task);
196 }
197
198 $db->commit();
199 } catch (\Exception $e) {
200 $db->rollBack();
201 return $this->sendError(['message' => $e->getMessage()], 400);
202 }
203
204 $task->load('labels');
205
206 return $this->sendSuccess([
207 'task' => $task,
208 'labels' => $task->labels,
209 'priority' => $task->priority,
210 'message' => __('Suggestions applied.', 'fluent-boards'),
211 ]);
212 }
213
214 /**
215 * Most recent comments, oldest-first, with each one capped so a single long
216 * comment cannot dominate the prompt budget.
217 */
218 private function collectTaskComments($taskId)
219 {
220 $comments = Comment::where('task_id', $taskId)
221 ->where('type', 'comment')
222 ->orderBy('id', 'desc')
223 ->limit(self::MAX_SUMMARY_COMMENTS)
224 ->get();
225
226 $lines = [];
227 foreach ($comments as $comment) {
228 $text = trim(wp_strip_all_tags((string) $comment->description));
229 if ($text === '') {
230 continue;
231 }
232 if (mb_strlen($text) > self::MAX_COMMENT_CHARS) {
233 $text = mb_substr($text, 0, self::MAX_COMMENT_CHARS) . '';
234 }
235 $lines[] = '- ' . $text;
236 }
237
238 // Re-order chronologically now that the newest have been selected.
239 return implode("\n", array_reverse($lines));
240 }
241 }
242