diff --git a/app/Http/Controllers/Api/DialogController.php b/app/Http/Controllers/Api/DialogController.php index 1d4526799..009370bad 100755 --- a/app/Http/Controllers/Api/DialogController.php +++ b/app/Http/Controllers/Api/DialogController.php @@ -33,6 +33,8 @@ use App\Models\WebSocketDialogSession; use App\Models\UserRecentItem; use App\Module\Table\OnlineData; use App\Module\Manticore\ManticoreMsg; +use App\Module\Apps; +use App\Tasks\AiDialogCommandTask; use Hhxsv5\LaravelS\Swoole\Task\Task; /** @@ -3555,4 +3557,76 @@ class DialogController extends AbstractController // return Base::retSuccess('重命名成功', $session); } + + /** + * @api {post} api/dialog/ai/command 执行AI命令 + * + * @apiDescription 需要token身份,在对话中执行 AI 命令(/analyze, /summarize) + * @apiVersion 1.0.0 + * @apiGroup dialog + * @apiName ai__command + * + * @apiParam {Number} dialog_id 对话ID + * @apiParam {String} command 命令名称 (analyze/summarize) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function ai__command() + { + $user = User::auth(); + + // 检查 AI 插件是否安装 + if (!Apps::isInstalled('ai')) { + return Base::retError('AI 助手未安装'); + } + + $dialogId = intval(Request::input('dialog_id')); + $command = trim(Request::input('command', '')); + + // 验证命令 + if (!in_array($command, ['analyze', 'summarize'])) { + return Base::retError('无效的命令'); + } + + // 验证对话存在 + $dialog = WebSocketDialog::find($dialogId); + if (!$dialog) { + return Base::retError('对话不存在'); + } + + // 检查用户是否在对话中 + if (!WebSocketDialogUser::whereDialogId($dialogId)->whereUserid($user->userid)->exists()) { + return Base::retError('无权限访问此对话'); + } + + // 检查是否有正在进行的 AI 命令(防止并发) + $lockKey = "ai_dialog_command:{$dialogId}"; + if (Cache::has($lockKey)) { + return Base::retError('当前对话正在处理 AI 命令,请稍候再试'); + } + + // 设置锁,有效期 3 分钟(AI 任务超时时间为 120 秒) + Cache::put($lockKey, true, Carbon::now()->addMinutes(3)); + + // 发送"正在处理"提示消息(notice 类型,前端自动翻译) + $noticeKey = $command === 'analyze' ? '正在分析,请稍候...' : '正在总结,请稍候...'; + $result = WebSocketDialogMsg::sendMsg( + null, + $dialogId, + 'notice', + ['notice' => $noticeKey], + \App\Module\AiDialogCommand::AI_ASSISTANT_USERID, + true, // push_self + false, // push_retry + true // push_silence + ); + $notifyMsgId = $result['data']->id ?? 0; + + // 投递异步任务 + Task::deliver(new AiDialogCommandTask($dialogId, $command, $user->userid, $notifyMsgId)); + + return Base::retSuccess('命令已接受'); + } } diff --git a/app/Module/AiDialogCommand.php b/app/Module/AiDialogCommand.php new file mode 100644 index 000000000..3f5691e4d --- /dev/null +++ b/app/Module/AiDialogCommand.php @@ -0,0 +1,598 @@ +group_type) { + case 'task': + self::analyzeTask($dialog, $userId, $lang, $notifyMsgId); + break; + case 'project': + self::analyzeProject($dialog, $userId, $lang, $notifyMsgId); + break; + default: + self::updateNotifyMessage($dialog, $notifyMsgId, '/analyze 命令仅在任务和项目对话中可用', 'error'); + break; + } + } + + /** + * 执行 /summarize 命令 + */ + public static function summarize(WebSocketDialog $dialog, int $userId, int $notifyMsgId = 0): void + { + $lang = self::getUserLanguage($userId); + + switch ($dialog->group_type) { + case 'task': + self::summarizeTask($dialog, $lang, $notifyMsgId); + break; + case 'project': + self::summarizeProject($dialog, $lang, $notifyMsgId); + break; + default: + self::summarizeGeneral($dialog, $lang, $notifyMsgId); + break; + } + } + + /** + * 获取用户语言(用于 AI 输出语言) + */ + private static function getUserLanguage(int $userId): string + { + $user = User::find($userId); + return $user->lang ?? 'zh'; + } + + /** + * 分析任务对话 - 复用 AiTaskSuggestion 逻辑 + */ + private static function analyzeTask(WebSocketDialog $dialog, int $userId, string $lang, int $notifyMsgId): void + { + $task = ProjectTask::with(['project', 'projectColumn'])->whereDialogId($dialog->id)->first(); + if (!$task) { + self::updateNotifyMessage($dialog, $notifyMsgId, '未找到关联的任务', 'error'); + return; + } + + $suggestions = []; + + // 检查并生成各类建议 + if (AiTaskSuggestion::shouldExecute($task, 'description')) { + $result = AiTaskSuggestion::generateDescription($task); + if ($result) $suggestions[] = $result; + } + + if (AiTaskSuggestion::shouldExecute($task, 'subtasks')) { + $result = AiTaskSuggestion::generateSubtasks($task); + if ($result) $suggestions[] = $result; + } + + if (AiTaskSuggestion::shouldExecute($task, 'assignee')) { + $result = AiTaskSuggestion::generateAssignee($task); + if ($result) $suggestions[] = $result; + } + + if (AiTaskSuggestion::shouldExecute($task, 'similar')) { + $result = AiTaskSuggestion::findSimilarTasks($task); + if ($result) $suggestions[] = $result; + } + + if (empty($suggestions)) { + self::updateNotifyMessage($dialog, $notifyMsgId, '当前任务状态良好,暂无建议', 'success'); + } else { + // 更新提示消息为成功 + self::updateNotifyMessage($dialog, $notifyMsgId, '分析完成', 'success'); + // 复用 AiTaskSuggestion 的消息构建和发送逻辑 + AiTaskSuggestion::sendSuggestionMessage($task, $suggestions); + } + } + + /** + * 分析项目对话 - 项目健康度分析 + */ + private static function analyzeProject(WebSocketDialog $dialog, int $userId, string $lang, int $notifyMsgId): void + { + $project = Project::whereDialogId($dialog->id)->first(); + if (!$project) { + self::updateNotifyMessage($dialog, $notifyMsgId, '未找到关联的项目', 'error'); + return; + } + + // 收集项目统计数据 + $stats = self::getProjectHealthStats($project); + + // 构建分析 prompt + $prompt = self::buildProjectAnalysisPrompt($project, $stats, $lang); + + // 调用 AI + $result = AI::invoke([ + ['system', '你是 DooTask 任务管理系统的 AI 助手,擅长分析项目健康度并给出改进建议。'], + ['user', $prompt], + ], 120); + + if (Base::isError($result)) { + self::updateNotifyMessage($dialog, $notifyMsgId, $result['msg'] ?? 'AI 分析失败', 'error'); + return; + } + + $content = $result['data']['content'] ?? ''; + if (empty($content)) { + self::updateNotifyMessage($dialog, $notifyMsgId, 'AI 未返回有效内容', 'error'); + return; + } + + // 更新提示消息为成功 + self::updateNotifyMessage($dialog, $notifyMsgId, '分析完成', 'success'); + // 发送分析结果 + self::sendMessage($dialog, $content); + } + + /** + * 获取项目健康度统计数据 + */ + private static function getProjectHealthStats(Project $project): array + { + $projectId = $project->id; + $now = Carbon::now(); + + // 基础统计 - 只统计主任务 + $baseBuilder = ProjectTask::whereProjectId($projectId) + ->whereNull('archived_at') + ->whereNull('deleted_at') + ->where('parent_id', 0); + + $totalTasks = (clone $baseBuilder)->count(); + + $completedTasks = (clone $baseBuilder) + ->whereNotNull('complete_at') + ->count(); + + // 逾期任务(未完成且已超过截止日期) + $overdueTasks = (clone $baseBuilder) + ->whereNull('complete_at') + ->whereNotNull('end_at') + ->where('end_at', '<', $now) + ->get(['id', 'name', 'end_at']); + + // 今日到期任务 + $todayTasks = (clone $baseBuilder) + ->whereNull('complete_at') + ->whereDate('end_at', $now->toDateString()) + ->get(['id', 'name']); + + // 无负责人任务 + $unassignedTasks = (clone $baseBuilder) + ->whereNull('complete_at') + ->whereDoesntHave('taskUser', function ($q) { + $q->where('owner', 1); + }) + ->get(['id', 'name']); + + return [ + 'total' => $totalTasks, + 'completed' => $completedTasks, + 'completion_rate' => $totalTasks > 0 ? round($completedTasks / $totalTasks * 100, 1) : 0, + 'overdue' => $overdueTasks->toArray(), + 'overdue_count' => $overdueTasks->count(), + 'today' => $todayTasks->toArray(), + 'today_count' => $todayTasks->count(), + 'unassigned' => $unassignedTasks->toArray(), + 'unassigned_count' => $unassignedTasks->count(), + ]; + } + + /** + * 构建项目分析 Prompt + */ + private static function buildProjectAnalysisPrompt(Project $project, array $stats, string $lang): string + { + $langName = Doo::getLanguages($lang) ?: '简体中文'; + + $overdueList = ''; + foreach (array_slice($stats['overdue'], 0, 5) as $task) { + $endAt = Carbon::parse($task['end_at'])->format('Y-m-d'); + $overdueList .= "- #{$task['id']} {$task['name']} (截止: {$endAt})\n"; + } + if ($stats['overdue_count'] > 5) { + $overdueList .= "- ... 还有 " . ($stats['overdue_count'] - 5) . " 个逾期任务\n"; + } + + $todayList = ''; + foreach (array_slice($stats['today'], 0, 5) as $task) { + $todayList .= "- #{$task['id']} {$task['name']}\n"; + } + + $unassignedList = ''; + foreach (array_slice($stats['unassigned'], 0, 5) as $task) { + $unassignedList .= "- #{$task['id']} {$task['name']}\n"; + } + if ($stats['unassigned_count'] > 5) { + $unassignedList .= "- ... 还有 " . ($stats['unassigned_count'] - 5) . " 个无负责人任务\n"; + } + + $projectDesc = mb_substr($project->desc ?? '', 0, 200); + + return <<name} +项目描述:{$projectDesc} + +统计数据: +- 总任务数:{$stats['total']} +- 已完成:{$stats['completed']} +- 完成率:{$stats['completion_rate']}% +- 逾期任务数:{$stats['overdue_count']} +- 今日到期:{$stats['today_count']} +- 无负责人任务:{$stats['unassigned_count']} + +逾期任务(前5个): +{$overdueList} + +今日到期任务: +{$todayList} + +无负责人任务(前5个): +{$unassignedList} + +输出要求: +1. 使用 Markdown 格式 +2. 先给出项目健康度评分(优秀/良好/一般/需关注),用 emoji 标识 +3. 分析当前存在的主要问题 +4. 列出需要立即关注的事项 +5. 给出具体的改进建议 +6. 使用{$langName}输出 +7. 保持简洁专业,不超过 500 字 +PROMPT; + } + + /** + * 总结任务对话 + */ + private static function summarizeTask(WebSocketDialog $dialog, string $lang, int $notifyMsgId): void + { + $task = ProjectTask::whereDialogId($dialog->id)->first(); + if (!$task) { + self::updateNotifyMessage($dialog, $notifyMsgId, '未找到关联的任务', 'error'); + return; + } + + // 获取最近消息 + $messages = self::getRecentMessages($dialog->id, 50); + + if (empty($messages)) { + self::updateNotifyMessage($dialog, $notifyMsgId, '暂无可供总结的消息记录', 'success'); + return; + } + + // 构建总结 prompt + $prompt = self::buildTaskSummaryPrompt($task, $messages, $lang); + + // 调用 AI + $result = AI::invoke([ + ['system', '你是 DooTask 任务管理系统的 AI 助手,擅长总结任务讨论内容和进展情况。'], + ['user', $prompt], + ], 120); + + if (Base::isError($result)) { + self::updateNotifyMessage($dialog, $notifyMsgId, $result['msg'] ?? 'AI 总结失败', 'error'); + return; + } + + $content = $result['data']['content'] ?? ''; + if (empty($content)) { + self::updateNotifyMessage($dialog, $notifyMsgId, 'AI 未返回有效内容', 'error'); + return; + } + + // 更新提示消息为成功 + self::updateNotifyMessage($dialog, $notifyMsgId, '总结完成', 'success'); + // 发送总结结果 + self::sendMessage($dialog, $content); + } + + /** + * 总结项目对话 + */ + private static function summarizeProject(WebSocketDialog $dialog, string $lang, int $notifyMsgId): void + { + $project = Project::whereDialogId($dialog->id)->first(); + if (!$project) { + self::updateNotifyMessage($dialog, $notifyMsgId, '未找到关联的项目', 'error'); + return; + } + + // 获取最近消息 + $messages = self::getRecentMessages($dialog->id, 50); + + if (empty($messages)) { + self::updateNotifyMessage($dialog, $notifyMsgId, '暂无可供总结的消息记录', 'success'); + return; + } + + // 构建总结 prompt + $prompt = self::buildProjectSummaryPrompt($project, $messages, $lang); + + // 调用 AI + $result = AI::invoke([ + ['system', '你是 DooTask 任务管理系统的 AI 助手,擅长总结项目讨论内容。'], + ['user', $prompt], + ], 120); + + if (Base::isError($result)) { + self::updateNotifyMessage($dialog, $notifyMsgId, $result['msg'] ?? 'AI 总结失败', 'error'); + return; + } + + $content = $result['data']['content'] ?? ''; + if (empty($content)) { + self::updateNotifyMessage($dialog, $notifyMsgId, 'AI 未返回有效内容', 'error'); + return; + } + + // 更新提示消息为成功 + self::updateNotifyMessage($dialog, $notifyMsgId, '总结完成', 'success'); + // 发送总结结果 + self::sendMessage($dialog, $content); + } + + /** + * 总结普通对话 + */ + private static function summarizeGeneral(WebSocketDialog $dialog, string $lang, int $notifyMsgId): void + { + // 获取最近消息 + $messages = self::getRecentMessages($dialog->id, 50); + + if (empty($messages)) { + self::updateNotifyMessage($dialog, $notifyMsgId, '暂无可供总结的消息记录', 'success'); + return; + } + + // 构建总结 prompt + $prompt = self::buildGeneralSummaryPrompt($dialog, $messages, $lang); + + // 调用 AI + $result = AI::invoke([ + ['system', '你是 DooTask 任务管理系统的 AI 助手,擅长总结聊天内容。'], + ['user', $prompt], + ], 120); + + if (Base::isError($result)) { + self::updateNotifyMessage($dialog, $notifyMsgId, $result['msg'] ?? 'AI 总结失败', 'error'); + return; + } + + $content = $result['data']['content'] ?? ''; + if (empty($content)) { + self::updateNotifyMessage($dialog, $notifyMsgId, 'AI 未返回有效内容', 'error'); + return; + } + + // 更新提示消息为成功 + self::updateNotifyMessage($dialog, $notifyMsgId, '总结完成', 'success'); + // 发送总结结果 + self::sendMessage($dialog, $content); + } + + /** + * 获取最近消息 + */ + private static function getRecentMessages(int $dialogId, int $limit = 50): array + { + $messages = WebSocketDialogMsg::whereDialogId($dialogId) + ->where('type', 'text') + ->where('userid', '>', 0) // 排除系统消息和 AI 消息 + ->orderBy('id', 'desc') + ->limit($limit) + ->get(); + + $result = []; + foreach ($messages->reverse() as $msg) { + $user = User::find($msg->userid); + $text = $msg->msg['text'] ?? ''; + // 移除 HTML 标签,保留纯文本 + $text = strip_tags($text); + $text = mb_substr(trim($text), 0, 500); + + if (!empty($text)) { + $result[] = [ + 'user' => $user ? $user->nickname : '未知用户', + 'text' => $text, + 'time' => $msg->created_at->format('Y-m-d H:i'), + ]; + } + } + + return $result; + } + + /** + * 构建任务总结 Prompt + */ + private static function buildTaskSummaryPrompt(ProjectTask $task, array $messages, string $lang): string + { + $langName = Doo::getLanguages($lang) ?: '简体中文'; + + $chatContent = ''; + foreach ($messages as $msg) { + $chatContent .= "[{$msg['time']}] {$msg['user']}: {$msg['text']}\n"; + } + + $taskStatus = $task->complete_at ? '已完成' : '进行中'; + $progress = $task->percent ?? 0; + + return <<name} +任务状态:{$taskStatus} +完成进度:{$progress}% + +讨论记录: +{$chatContent} + +输出要求: +1. 使用 Markdown 格式 +2. 总结主要讨论内容和关键决策 +3. 列出待解决的问题(如有) +4. 总结任务当前进展和下一步计划 +5. 使用{$langName}输出 +6. 保持简洁,不超过 300 字 +PROMPT; + } + + /** + * 构建项目总结 Prompt + */ + private static function buildProjectSummaryPrompt(Project $project, array $messages, string $lang): string + { + $langName = Doo::getLanguages($lang) ?: '简体中文'; + + $chatContent = ''; + foreach ($messages as $msg) { + $chatContent .= "[{$msg['time']}] {$msg['user']}: {$msg['text']}\n"; + } + + return <<name} + +讨论记录: +{$chatContent} + +输出要求: +1. 使用 Markdown 格式 +2. 总结主要讨论话题 +3. 列出关键决策和结论 +4. 列出待处理事项(如有) +5. 使用{$langName}输出 +6. 保持简洁,不超过 300 字 +PROMPT; + } + + /** + * 构建普通对话总结 Prompt + */ + private static function buildGeneralSummaryPrompt(WebSocketDialog $dialog, array $messages, string $lang): string + { + $langName = Doo::getLanguages($lang) ?: '简体中文'; + + $chatContent = ''; + foreach ($messages as $msg) { + $chatContent .= "[{$msg['time']}] {$msg['user']}: {$msg['text']}\n"; + } + + $dialogName = $dialog->name ?? '对话'; + + return <<id, + 'text', + ['text' => $content, 'type' => 'md'], + self::AI_ASSISTANT_USERID, + true, // push_self + false, // push_retry + true // push_silence + ); + } + + /** + * 更新提示消息 + * @param WebSocketDialog $dialog 对话 + * @param int $notifyMsgId 提示消息ID + * @param string $content 新内容 + * @param string $status 状态: success/error + */ + private static function updateNotifyMessage(WebSocketDialog $dialog, int $notifyMsgId, string $content, string $status): void + { + // 清除并发锁 + self::releaseLock($dialog->id); + + if ($notifyMsgId <= 0) { + // 没有提示消息ID,直接发送新消息 + self::sendMessage($dialog, $content); + return; + } + + // 根据状态添加前缀图标 + $prefix = $status === 'error' ? '❌ ' : '✅ '; + $noticeContent = $prefix . $content; + + // 更新消息(不带 source=api,让前端自动翻译) + WebSocketDialogMsg::sendMsg( + 'update-' . $notifyMsgId, + $dialog->id, + 'notice', + ['notice' => $noticeContent], + self::AI_ASSISTANT_USERID, + true, // push_self + false, // push_retry + true // push_silence + ); + } + + /** + * 释放对话的 AI 命令锁 + */ + private static function releaseLock(int $dialogId): void + { + Cache::forget("ai_dialog_command:{$dialogId}"); + } + +} diff --git a/app/Tasks/AiDialogCommandTask.php b/app/Tasks/AiDialogCommandTask.php new file mode 100644 index 000000000..c69597d64 --- /dev/null +++ b/app/Tasks/AiDialogCommandTask.php @@ -0,0 +1,54 @@ +dialogId = $dialogId; + $this->command = $command; + $this->userId = $userId; + $this->notifyMsgId = $notifyMsgId; + } + + public function start() + { + $dialog = WebSocketDialog::find($this->dialogId); + if (!$dialog) { + // 对话不存在,释放锁 + Cache::forget("ai_dialog_command:{$this->dialogId}"); + return; + } + + try { + match ($this->command) { + 'analyze' => AiDialogCommand::analyze($dialog, $this->userId, $this->notifyMsgId), + 'summarize' => AiDialogCommand::summarize($dialog, $this->userId, $this->notifyMsgId), + default => null, + }; + } catch (\Throwable $e) { + // 异常时释放锁,避免死锁 + Cache::forget("ai_dialog_command:{$this->dialogId}"); + throw $e; + } + } + + public function end() + { + } +} diff --git a/resources/assets/js/pages/manage/components/ChatInput/index.vue b/resources/assets/js/pages/manage/components/ChatInput/index.vue index da6936b49..a719f11f8 100755 --- a/resources/assets/js/pages/manage/components/ChatInput/index.vue +++ b/resources/assets/js/pages/manage/components/ChatInput/index.vue @@ -1220,6 +1220,7 @@ export default { }, quillMention() { + const self = this; return { allowedChars: /^\S*$/, mentionDenotationChars: ["@", "#", "~", "%", "/"], @@ -1255,6 +1256,19 @@ export default { } if (["@", "#", "~", "%"].includes(item.tip)) { this.openMenu(item.tip); + } else if (['/analyze', '/summarize'].includes(item.tip)) { + // AI 命令:调用后端 API + const command = item.tip.slice(1); + $A.apiCall({ + url: 'dialog/ai/command', + method: 'post', + data: { + dialog_id: self.dialogId, + command: command, + }, + }).catch(({msg}) => { + $A.messageError(msg); + }); } else { const insertText = item.tip.endsWith(' ') ? item.tip : `${item.tip} `; const insertAt = typeof mentionCharPos === 'number' ? mentionCharPos : (this.quill.getSelection(true)?.index || 0); @@ -2725,6 +2739,16 @@ export default { value: this.$L('工作报告'), tip: '%', }, + { + id: 'analyze', + value: this.$L('分析'), + tip: '/analyze', + }, + { + id: 'summarize', + value: this.$L('总结'), + tip: '/summarize', + }, ] }]; if (showBotCommands) {