content ?? ''); return empty($content) || mb_strlen($content) < 20; case ProjectTaskAiEvent::EVENT_SUBTASKS: // 无子任务且标题长度 > 5 $hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists(); return !$hasSubtasks && mb_strlen($task->name) > 5; case ProjectTaskAiEvent::EVENT_ASSIGNEE: // 未指定负责人 $hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists(); return !$hasOwner; case ProjectTaskAiEvent::EVENT_SIMILAR: // 需要安装 search 插件才能使用向量搜索 return Apps::isInstalled('search'); default: return false; } } /** * 生成任务描述建议 */ public static function generateDescription(ProjectTask $task): ?array { $prompt = self::buildDescriptionPrompt($task); $result = self::callAi($prompt); if (empty($result)) { return null; } return [ 'type' => 'description', 'content' => $result, ]; } /** * 生成子任务拆分建议 */ public static function generateSubtasks(ProjectTask $task): ?array { $prompt = self::buildSubtasksPrompt($task); $result = self::callAi($prompt); if (empty($result)) { return null; } // 解析返回的子任务列表 $subtasks = self::parseSubtasksList($result); if (empty($subtasks)) { return null; } return [ 'type' => 'subtasks', 'content' => $subtasks, ]; } /** * 生成负责人推荐 */ public static function generateAssignee(ProjectTask $task): ?array { // 获取当前任务已有的成员(负责人和协助人) $existingUserIds = ProjectTaskUser::where('task_id', $task->id) ->pluck('userid') ->toArray(); // 获取项目成员,排除已有任务成员 $members = self::getProjectMembersInfo($task->project_id); $members = array_filter($members, function ($member) use ($existingUserIds) { return !in_array($member['userid'], $existingUserIds); }); $members = array_values($members); // 重新索引 if (empty($members)) { return null; } $prompt = self::buildAssigneePrompt($task, $members); $result = self::callAi($prompt); if (empty($result)) { return null; } // 解析推荐结果 $recommendations = self::parseAssigneeRecommendations($result, $members); if (empty($recommendations)) { return null; } return [ 'type' => 'assignee', 'content' => $recommendations, ]; } /** * 搜索相似任务 */ public static function findSimilarTasks(ProjectTask $task): ?array { // 使用 AI 模块的 Embedding 搜索 $searchText = $task->name; if (empty($searchText)) { return null; } try { $result = AI::getEmbedding($searchText); if (Base::isError($result) || empty($result['data'])) { return null; } $embedding = $result['data']; // 搜索相似任务(排除自己和子任务) $similarTasks = self::searchSimilarByEmbedding( $embedding, $task->project_id, $task->id ); if (empty($similarTasks)) { return null; } return [ 'type' => 'similar', 'content' => $similarTasks, ]; } catch (\Exception $e) { \Log::error('AiTaskSuggestion::findSimilarTasks error: ' . $e->getMessage()); return null; } } /** * 转义用户输入以防止 Prompt 注入 */ private static function escapeUserInput(string $input, int $length = 500): string { // 移除可能影响 AI Prompt 解析的特殊字符 $input = str_replace(['```', '---', '==='], '', $input); // 截断过长的输入 return mb_substr(trim($input), 0, $length); } /** * 构建描述生成 Prompt */ private static function buildDescriptionPrompt(ProjectTask $task): string { $taskName = self::escapeUserInput($task->name, 100); $projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100); $columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50); return <<name, 100); $projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100); $columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50); $content = self::escapeUserInput($task->content ?? ''); return <<name, 100); $projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100); $columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50); $taskContent = self::escapeUserInput($task->content ?? ''); $membersText = ''; foreach ($members as $member) { $nickname = self::escapeUserInput($member['nickname'], 20); $membersText .= "- {$nickname}(ID:{$member['userid']})"; if (!empty($member['profession'])) { $profession = self::escapeUserInput($member['profession'], 50); $membersText .= ",职位:{$profession}"; } $membersText .= ",进行中:{$member['in_progress_count']}个"; $membersText .= ",近期完成:{$member['completed_count']}个"; $membersText .= "\n"; } return <<getMessage()); return null; } } /** * 获取项目成员信息 */ private static function getProjectMembersInfo(int $projectId): array { $projectUsers = ProjectUser::where('project_id', $projectId)->get(); $members = []; foreach ($projectUsers as $pu) { $user = User::find($pu->userid); if (!$user || $user->bot || $user->disable_at) { continue; } // 获取进行中任务数量 $inProgressCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id') ->where('project_task_users.userid', $user->userid) ->whereNull('project_tasks.complete_at') ->whereNull('project_tasks.archived_at') ->whereNull('project_tasks.deleted_at') ->count(); // 获取近期完成任务数量 $completedCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id') ->where('project_task_users.userid', $user->userid) ->whereNotNull('project_tasks.complete_at') ->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(30)) ->whereNull('project_tasks.deleted_at') ->count(); $members[] = [ 'userid' => $user->userid, 'nickname' => $user->nickname, 'profession' => $user->profession ?? '', 'in_progress_count' => $inProgressCount, 'completed_count' => $completedCount, ]; } return $members; } /** * 解析子任务列表 */ private static function parseSubtasksList(string $text): array { $lines = explode("\n", trim($text)); $subtasks = []; foreach ($lines as $line) { $line = trim($line); // 移除序号前缀 $line = preg_replace('/^\d+[\.\)、]\s*/', '', $line); if (!empty($line) && mb_strlen($line) <= 100) { $subtasks[] = $line; } } return array_slice($subtasks, 0, 5); // 最多5个 } /** * 解析负责人推荐结果 */ private static function parseAssigneeRecommendations(string $text, array $members): array { $memberMap = []; foreach ($members as $m) { $memberMap[$m['userid']] = $m; } $lines = explode("\n", trim($text)); $recommendations = []; $addedUserIds = []; // 记录已添加的用户ID,防止重复 foreach ($lines as $line) { $line = trim($line); $line = preg_replace('/^\d+[\.\)、]\s*/', '', $line); if (preg_match('/^(\d+)\|(.+)$/', $line, $matches)) { $userid = intval($matches[1]); $reason = trim($matches[2]); // 跳过已添加的用户 if (in_array($userid, $addedUserIds)) { continue; } if (isset($memberMap[$userid])) { $recommendations[] = [ 'userid' => $userid, 'nickname' => $memberMap[$userid]['nickname'], 'reason' => $reason, ]; $addedUserIds[] = $userid; } } } return array_slice($recommendations, 0, 2); // 最多2个 } /** * 通过 Embedding 搜索相似任务 * * @param array $embedding 任务内容的向量表示 * @param int $projectId 项目ID(用于过滤同项目任务) * @param int $excludeTaskId 排除的任务ID(当前任务) * @return array 相似任务列表 */ private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array { if (empty($embedding)) { return []; } try { // 使用 ManticoreBase 进行向量搜索 // userid=0 跳过权限过滤,我们通过 project_id 过滤 $results = ManticoreBase::taskVectorSearch($embedding, 0, 200); if (empty($results)) { return []; } // 获取当前任务的子任务ID列表 $childTaskIds = ProjectTask::where('parent_id', $excludeTaskId) ->whereNull('deleted_at') ->pluck('id') ->toArray(); // 过滤:同项目、排除当前任务及其子任务、相似度阈值 $similarTasks = []; foreach ($results as $item) { // 过滤不同项目的任务 if ($item['project_id'] != $projectId) { continue; } // 排除当前任务 if ($item['task_id'] == $excludeTaskId) { continue; } // 排除子任务 if (in_array($item['task_id'], $childTaskIds)) { continue; } // 相似度阈值(0.5 以上才算相似) $similarity = $item['similarity'] ?? 0; if ($similarity < 0.5) { continue; } $similarTasks[] = [ 'task_id' => $item['task_id'], 'name' => $item['task_name'] ?? '', 'similarity' => round($similarity, 2), ]; // 最多返回 5 个相似任务 if (count($similarTasks) >= 5) { break; } } return $similarTasks; } catch (\Exception $e) { \Log::error('searchSimilarByEmbedding error: ' . $e->getMessage()); return []; } } /** * 构建 Markdown 消息 */ public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0): string { $parts = []; foreach ($suggestions as $suggestion) { switch ($suggestion['type']) { case 'description': $parts[] = self::buildDescriptionMarkdown($taskId, $msgId, $suggestion['content']); break; case 'subtasks': $parts[] = self::buildSubtasksMarkdown($taskId, $msgId, $suggestion['content']); break; case 'assignee': $parts[] = self::buildAssigneeMarkdown($taskId, $msgId, $suggestion['content']); break; case 'similar': $parts[] = self::buildSimilarMarkdown($taskId, $msgId, $suggestion['content']); break; } } return implode("\n\n---\n\n", $parts); } /** * 构建描述建议 Markdown */ private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content): string { return << $name) { $num = $i + 1; $list .= "{$num}. {$name}\n"; } return << $st) { $num = $i + 1; $stTaskId = $st['task_id']; $viewUrl = "dootask://task/{$stTaskId}"; $list .= "{$num}. **[#{$stTaskId}]({$viewUrl})** {$st['name']} :::ai-action{type=\"similar\" task=\"{$taskId}\" msg=\"{$msgId}\" related=\"{$stTaskId}\"}:::\n"; } return <<dialog_id) { $dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task'); if ($dialog) { $task->dialog_id = $dialog->id; $task->save(); $task->pushMsg('dialog'); } else { return null; } } // 先发送消息获取 msg_id,然后更新消息内容带上 msg_id $tempMarkdown = self::buildMarkdownMessage($task->id, $suggestions, 0); $result = WebSocketDialogMsg::sendMsg( null, $task->dialog_id, 'text', ['text' => $tempMarkdown, 'type' => 'md'], self::AI_ASSISTANT_USERID, true, // push_self false, // push_retry true // push_silence ); if (Base::isError($result)) { return null; } $msgId = $result['data']->id ?? 0; if (empty($msgId)) { return null; } // 更新消息,带上真实的 msg_id $finalMarkdown = self::buildMarkdownMessage($task->id, $suggestions, $msgId); WebSocketDialogMsg::sendMsg( 'change-' . $msgId, $task->dialog_id, 'text', ['text' => $finalMarkdown, 'type' => 'md'], self::AI_ASSISTANT_USERID, true, // push_self ); return $msgId; } /** * 更新消息状态(采纳/忽略后) * * @param int $msgId 消息ID * @param int $dialogId 对话ID * @param string $type 建议类型 * @param string $status 状态:applied/dismissed * @param int $userid 用户ID(assignee类型单独处理时使用) * @param int $related 关联任务ID(similar类型单独处理时使用) * @return array 更新后的消息数据 */ public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status, int $userid = 0, int $related = 0): array { // 验证消息存在且属于指定对话 $msg = WebSocketDialogMsg::where('id', $msgId) ->where('dialog_id', $dialogId) ->first(); if (!$msg) { return Base::retError('消息不存在'); } $content = $msg->msg['text'] ?? ''; if (empty($content)) { return Base::retError('消息内容为空'); } // 根据类型和参数构建匹配模式,添加 status 属性 if ($type === 'assignee' && $userid > 0) { $pattern = '/(:::ai-action\{type="assignee"[^}]*userid="' . $userid . '"[^}]*)\}:::/'; } elseif ($type === 'similar' && $related > 0) { $pattern = '/(:::ai-action\{type="similar"[^}]*related="' . $related . '"[^}]*)\}:::/'; } else { $pattern = '/(:::ai-action\{type="' . preg_quote($type, '/') . '"[^}]*)\}:::/'; } $newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content); // 更新消息并返回结果 return WebSocketDialogMsg::sendMsg( 'change-' . $msgId, $dialogId, 'text', ['text' => $newContent, 'type' => 'md'], self::AI_ASSISTANT_USERID ); } }