content ?? ''); return empty($content) || mb_strlen($content) < 20; case ProjectTaskAiEvent::EVENT_SUBTASKS: // 无子任务且标题长度 > 10 $hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists(); return !$hasSubtasks && mb_strlen($task->name) > 10; case ProjectTaskAiEvent::EVENT_ASSIGNEE: // 未指定负责人 $hasOwner = ProjectTaskUser::where('task_id', $task->id) ->where('owner', 1) ->exists(); return !$hasOwner; case ProjectTaskAiEvent::EVENT_SIMILAR: // 始终执行 return true; 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 { // 获取项目成员 $members = self::getProjectMembersInfo($task->project_id); 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 { $embedding = AI::getEmbedding($searchText); if (empty($embedding)) { return null; } // 搜索相似任务(排除自己和子任务) $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 buildDescriptionPrompt(ProjectTask $task): string { $projectName = $task->project->name ?? '未知项目'; return <<name} 所属项目:{$projectName} 请按以下格式生成任务描述(使用 Markdown): > **背景**:[描述任务的背景和上下文] > **目标**:[明确任务要达成的目标] > **验收标准**: > - [验收标准1] > - [验收标准2] > - [验收标准3] 要求: 1. 内容要专业、简洁 2. 验收标准要具体、可衡量 3. 与用户输入语言保持一致 PROMPT; } /** * 构建子任务拆分 Prompt */ private static function buildSubtasksPrompt(ProjectTask $task): string { $content = $task->content ?? ''; return <<name} 任务描述:{$content} 请返回 3-5 个子任务,每行一个,格式如下: 1. [子任务名称] 2. [子任务名称] ... 要求: 1. 每个子任务要具体、可执行 2. 子任务之间有合理的顺序 3. 子任务名称简洁明了(不超过30字) 4. 只返回子任务列表,不要其他内容 PROMPT; } /** * 构建负责人推荐 Prompt */ private static function buildAssigneePrompt(ProjectTask $task, array $members): string { $membersText = ''; foreach ($members as $member) { $membersText .= "- {$member['nickname']}(ID:{$member['userid']})"; if (!empty($member['profession'])) { $membersText .= ",职位:{$member['profession']}"; } $membersText .= ",进行中任务:{$member['in_progress_count']}个"; $membersText .= ",近期完成:{$member['completed_count']}个"; if ($member['similar_count'] > 0) { $membersText .= ",处理过类似任务:{$member['similar_count']}个"; } $membersText .= "\n"; } return <<name} 任务描述:{$task->content} 团队成员: {$membersText} 请推荐 2 名最合适的负责人,按优先级排序,格式如下: 1. [userid]|[推荐理由,简短说明] 2. [userid]|[推荐理由,简短说明] 推荐依据: 1. 优先选择处理过类似任务的成员 2. 考虑当前工作负载(进行中任务较少的优先) 3. 考虑专业匹配度 只返回推荐列表,不要其他内容。 PROMPT; } /** * 调用 AI 接口 */ private static function callAi(string $prompt): ?string { try { // 使用 AI 模块调用 $result = AI::invoke([ ['system', '你是 DooTask 任务管理系统的 AI 助手,帮助用户管理任务。'], ['user', $prompt], ]); return $result['content'] ?? null; } catch (\Exception $e) { \Log::error('AiTaskSuggestion::callAi error: ' . $e->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 = []; 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 (isset($memberMap[$userid])) { $recommendations[] = [ 'userid' => $userid, 'nickname' => $memberMap[$userid]['nickname'], 'reason' => $reason, ]; } } } return array_slice($recommendations, 0, 2); // 最多2个 } /** * 通过 Embedding 搜索相似任务 */ private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array { // TODO: 实现向量搜索 // 当前先返回空数组,后续集成 SeekDB 或其他向量搜索 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", $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::find($msgId); 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 ); } }