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 . ' ' . ($task->content ?? ''); 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): string { // 移除可能影响 AI Prompt 解析的特殊字符 $input = str_replace(['```', '---', '==='], '', $input); // 截断过长的输入 return mb_substr(trim($input), 0, 500); } /** * 构建描述生成 Prompt */ private static function buildDescriptionPrompt(ProjectTask $task): string { $taskName = self::escapeUserInput($task->name); $projectName = self::escapeUserInput($task->project->name ?? '未知项目'); return <<name); $content = self::escapeUserInput($task->content ?? ''); return << 0) { $membersText .= ",处理过类似任务:{$member['similar_count']}个"; } $membersText .= "\n"; } $taskName = self::escapeUserInput($task->name); $taskContent = self::escapeUserInput($task->content ?? ''); 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, 'similar_count' => 0, // TODO: 计算相似任务数量 ]; } 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, 50); 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.7 以上才算相似) $similarity = $item['similarity'] ?? 0; if ($similarity < 0.7) { 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 = ["## AI 任务建议\n"]; 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 { $applyUrl = "dootask://ai-apply/description/{$taskId}/{$msgId}"; $dismissUrl = "dootask://ai-dismiss/description/{$taskId}/{$msgId}"; return << $name) { $num = $i + 1; $list .= "{$num}. {$name}\n"; } $applyUrl = "dootask://ai-apply/subtasks/{$taskId}/{$msgId}"; $dismissUrl = "dootask://ai-dismiss/subtasks/{$taskId}/{$msgId}"; return << $st) { $num = $i + 1; $viewUrl = "dootask://task/{$st['id']}"; $addUrl = "dootask://ai-apply/similar/{$taskId}/{$msgId}?related={$st['id']}"; $similarity = round($st['similarity'] * 100); $list .= "{$num}. **#{$st['id']} {$st['name']}** - 相似度 {$similarity}%\n"; $list .= " [查看任务]({$viewUrl}) [添加关联]({$addUrl})\n\n"; } $dismissUrl = "dootask://ai-dismiss/similar/{$taskId}/{$msgId}"; return <<dialog_id)) { 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::isSuccess($result)) { $msgId = $result['data']->id ?? 0; if ($msgId > 0) { // 更新消息,带上真实的 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 ); return $msgId; } } return null; } /** * 更新消息状态(采纳/忽略后) */ public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status): void { // 验证消息存在且属于指定对话 $msg = WebSocketDialogMsg::where('id', $msgId) ->where('dialog_id', $dialogId) ->first(); if (!$msg) { return; } $content = $msg->msg['text'] ?? ''; if (empty($content)) { return; } // 根据状态替换对应的按钮 $statusText = $status === 'applied' ? '✓ 已采纳' : '✗ 已忽略'; // 替换对应类型的按钮为状态文字 $pattern = '/\[.*?\]\(dootask:\/\/ai-(apply|dismiss)\/' . preg_quote($type, '/') . '\/\d+\/\d+[^)]*\)\s*/'; $newContent = preg_replace($pattern, '', $content); // 在对应标题后添加状态 $sectionTitles = [ 'description' => '### 建议补充任务描述', 'subtasks' => '### 建议拆分子任务', 'assignee' => '### 推荐负责人', 'similar' => '### 发现相似任务', ]; if (isset($sectionTitles[$type])) { $title = $sectionTitles[$type]; $newContent = str_replace($title, $title . "\n\n**{$statusText}**", $newContent); } // 更新消息 WebSocketDialogMsg::sendMsg( 'change-' . $msgId, $dialogId, 'text', ['text' => $newContent, 'type' => 'md'], self::AI_ASSISTANT_USERID, true, // push_self ); } }