From 29be29b9cfe463a5d73d8ac85fb3d359672fc74e Mon Sep 17 00:00:00 2001 From: kuaifan Date: Wed, 21 Jan 2026 12:09:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E4=BC=98=E5=8C=96=20AI=20=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E5=B9=B6=E5=AE=8C=E5=96=84=E5=BB=BA=E8=AE=AE?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化后端提示词:描述生成、子任务拆分、负责人推荐,新增栏目信息,去掉无效的 similar_count - 优化前端提示词:去掉硬性字数限制,即时消息改为简短输出 - 新增 :::ai-action{...}::: 语法处理,支持单独采纳/忽略 assignee 和 similar - 采纳/忽略后更新消息状态显示 - 负责人改为追加模式,保留现有负责人 - 新增任务关联功能,similar 采纳时自动创建双向关联 - 相似度阈值从 0.7 调整为 0.5,搜索结果增加到 200 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../Controllers/Api/ProjectController.php | 56 +++- app/Models/ProjectTaskRelation.php | 152 ++++++---- app/Module/AiTaskSuggestion.php | 274 +++++++++--------- app/Tasks/AiTaskAnalyzeTask.php | 7 +- app/Tasks/AiTaskLoopTask.php | 6 - .../manage/components/DialogMarkdown.vue | 62 +++- resources/assets/js/utils/ai.js | 19 +- resources/assets/js/utils/markdown.js | 60 ++++ .../components/dialog-markdown/markdown.less | 27 ++ 9 files changed, 428 insertions(+), 235 deletions(-) diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 5246dcec5..0fd317b0d 100755 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -3838,6 +3838,8 @@ class ProjectController extends AbstractController * @apiParam {Number} task_id 任务ID * @apiParam {Number} msg_id 消息ID * @apiParam {String} type 建议类型:description/subtasks/assignee/similar + * @apiParam {Number} [userid] 用户ID(assignee类型时用于指定采纳哪个推荐) + * @apiParam {Number} [related] 关联任务ID(similar类型时用于指定采纳哪个相似任务) * * @apiSuccess {Number} ret 返回状态码(1正确、0错误) * @apiSuccess {String} msg 返回信息(错误描述) @@ -3853,6 +3855,8 @@ class ProjectController extends AbstractController $taskId = intval(Request::input('task_id')); $msgId = intval(Request::input('msg_id')); $type = trim(Request::input('type')); + $userid = intval(Request::input('userid')); + $related = intval(Request::input('related')); // 验证建议类型 if (!in_array($type, ProjectTaskAiEvent::getEventTypes())) { @@ -3871,8 +3875,8 @@ class ProjectController extends AbstractController ->where('msg_id', $msgId) ->first(); - if (!$event || $event->status !== ProjectTaskAiEvent::STATUS_COMPLETED) { - return Base::retError('建议不存在或已处理'); + if (!$event) { + return Base::retError('建议不存在'); } $result = $event->result; @@ -3883,19 +3887,36 @@ class ProjectController extends AbstractController // 标记事件为已采纳 $event->markApplied(); - // 记录日志 - $task->addLog('AI建议:采纳' . $type . '建议'); - - // 更新消息状态 - if ($msgId > 0 && $task->dialog_id) { - AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'applied'); + // similar 类型:创建任务关联 + if ($type === 'similar' && $related > 0) { + ProjectTaskRelation::createRelation( + $taskId, + $related, + $task->dialog_id, + $msgId, + User::userid() + ); } - // 返回建议数据,由前端调用相应接口处理 + // 记录日志 + if ($type === 'assignee' && $userid > 0) { + $user = User::find($userid); + $task->addLog('AI建议:指派给 ' . ($user ? $user->nickname : $userid)); + } elseif ($type === 'similar' && $related > 0) { + $task->addLog('AI建议:关联任务 #' . $related); + } else { + $task->addLog('AI建议:采纳' . $type . '建议'); + } + + // 更新消息状态 + $msgResult = AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'applied', $userid, $related); + + // 返回建议数据和消息内容 return Base::retSuccess('已采纳', [ 'type' => $type, 'task_id' => $taskId, 'result' => $result, + 'msg' => $msgResult['data'] ?? null, ]); } @@ -3909,6 +3930,8 @@ class ProjectController extends AbstractController * @apiParam {Number} task_id 任务ID * @apiParam {Number} msg_id 消息ID * @apiParam {String} type 建议类型 + * @apiParam {Number} [userid] 用户ID(assignee类型时用于忽略单个推荐) + * @apiParam {Number} [related] 关联任务ID(similar类型时用于忽略单个推荐) * * @apiSuccess {Number} ret 返回状态码(1正确、0错误) * @apiSuccess {String} msg 返回信息(错误描述) @@ -3921,6 +3944,8 @@ class ProjectController extends AbstractController $taskId = intval(Request::input('task_id')); $msgId = intval(Request::input('msg_id')); $type = trim(Request::input('type')); + $userid = intval(Request::input('userid')); + $related = intval(Request::input('related')); // 验证建议类型 if (!in_array($type, ProjectTaskAiEvent::getEventTypes())) { @@ -3939,18 +3964,19 @@ class ProjectController extends AbstractController ->where('msg_id', $msgId) ->first(); - if (!$event || $event->status !== ProjectTaskAiEvent::STATUS_COMPLETED) { - return Base::retError('建议不存在或已处理'); + if (!$event) { + return Base::retError('建议不存在'); } // 标记事件为已忽略 $event->markDismissed(); // 更新消息状态 - if ($msgId > 0 && $task->dialog_id) { - AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'dismissed'); - } + $msgResult = AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'dismissed', $userid, $related); - return Base::retSuccess('已忽略'); + // 返回消息内容 + return Base::retSuccess('已忽略', [ + 'msg' => $msgResult['data'] ?? null, + ]); } } diff --git a/app/Models/ProjectTaskRelation.php b/app/Models/ProjectTaskRelation.php index aa83a6101..4ce8de48a 100644 --- a/app/Models/ProjectTaskRelation.php +++ b/app/Models/ProjectTaskRelation.php @@ -63,6 +63,86 @@ class ProjectTaskRelation extends AbstractModel return $this->belongsTo(ProjectTask::class, 'related_task_id'); } + /** + * 创建双向任务关联 + * + * @param int $sourceTaskId 源任务ID + * @param int $targetTaskId 目标任务ID + * @param int|null $dialogId 来源对话ID + * @param int|null $msgId 来源消息ID + * @param int|null $userid 操作人 + * @param bool $push 是否推送更新 + * @return bool 是否创建成功 + */ + public static function createRelation( + int $sourceTaskId, + int $targetTaskId, + ?int $dialogId = null, + ?int $msgId = null, + ?int $userid = null, + bool $push = true + ): bool { + if ($sourceTaskId === $targetTaskId) { + return false; + } + + $sourceTask = ProjectTask::with('project')->find($sourceTaskId); + $targetTask = ProjectTask::with('project')->find($targetTaskId); + + if (!$sourceTask || !$targetTask) { + return false; + } + + if ($sourceTask->deleted_at || $targetTask->deleted_at) { + return false; + } + + // 创建正向关联:源任务提及目标任务 + $mentionRelation = static::updateOrCreate( + [ + 'task_id' => $sourceTaskId, + 'related_task_id' => $targetTaskId, + 'direction' => self::DIRECTION_MENTION, + ], + [ + 'dialog_id' => $dialogId, + 'msg_id' => $msgId, + 'userid' => $userid, + ] + ); + + // 创建反向关联:目标任务被源任务提及 + $reverseRelation = static::updateOrCreate( + [ + 'task_id' => $targetTaskId, + 'related_task_id' => $sourceTaskId, + 'direction' => self::DIRECTION_MENTIONED_BY, + ], + [ + 'dialog_id' => $dialogId, + 'msg_id' => $msgId, + 'userid' => $userid, + ] + ); + + // 推送关联更新 + if ($push) { + $needPush = $mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged() + || $reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged(); + + if ($needPush) { + if ($sourceTask->project) { + $sourceTask->pushMsg('relation', null, null, false); + } + if ($targetTask->project) { + $targetTask->pushMsg('relation', null, null, false); + } + } + } + + return true; + } + public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void { if ($msg->type !== 'text') { @@ -84,71 +164,25 @@ class ProjectTaskRelation extends AbstractModel return; } - $sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get(); - if ($sourceTasks->isEmpty()) { + $sourceTaskIds = ProjectTask::whereDialogId($msg->dialog_id) + ->whereNull('deleted_at') + ->pluck('id') + ->toArray(); + + if (empty($sourceTaskIds)) { return; } - $targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id'); - if ($targetTasks->isEmpty()) { - return; - } - - $pushTasks = []; - foreach ($sourceTasks as $sourceTask) { + foreach ($sourceTaskIds as $sourceTaskId) { foreach ($targetIds as $targetId) { - if ($targetId === $sourceTask->id) { - continue; - } - - $targetTask = $targetTasks->get($targetId); - if (!$targetTask) { - continue; - } - - $mentionRelation = static::updateOrCreate( - [ - 'task_id' => $sourceTask->id, - 'related_task_id' => $targetTask->id, - 'direction' => self::DIRECTION_MENTION, - ], - [ - 'dialog_id' => $msg->dialog_id, - 'msg_id' => $msg->id, - 'userid' => $msg->userid, - ] + self::createRelation( + $sourceTaskId, + $targetId, + $msg->dialog_id, + $msg->id, + $msg->userid ); - - if ($mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()) { - $pushTasks[$sourceTask->id] = $sourceTask; - } - - $reverseRelation = static::updateOrCreate( - [ - 'task_id' => $targetTask->id, - 'related_task_id' => $sourceTask->id, - 'direction' => self::DIRECTION_MENTIONED_BY, - ], - [ - 'dialog_id' => $msg->dialog_id, - 'msg_id' => $msg->id, - 'userid' => $msg->userid, - ] - ); - - if ($reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged()) { - $pushTasks[$targetTask->id] = $targetTask; - } } } - - foreach ($pushTasks as $task) { - $task->loadMissing('project'); - if (!$task->project) { - continue; - } - - $task->pushMsg('relation', null, null, false); - } } } diff --git a/app/Module/AiTaskSuggestion.php b/app/Module/AiTaskSuggestion.php index f6f3154df..b13111c5c 100644 --- a/app/Module/AiTaskSuggestion.php +++ b/app/Module/AiTaskSuggestion.php @@ -7,6 +7,7 @@ use App\Models\ProjectTaskAiEvent; use App\Models\ProjectTaskUser; use App\Models\ProjectUser; use App\Models\User; +use App\Models\WebSocketDialog; use App\Models\WebSocketDialogMsg; use App\Module\Apps; use App\Module\Manticore\ManticoreBase; @@ -43,9 +44,7 @@ class AiTaskSuggestion case ProjectTaskAiEvent::EVENT_ASSIGNEE: // 未指定负责人 - $hasOwner = ProjectTaskUser::where('task_id', $task->id) - ->where('owner', 1) - ->exists(); + $hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists(); return !$hasOwner; case ProjectTaskAiEvent::EVENT_SIMILAR: @@ -145,7 +144,10 @@ class AiTaskSuggestion public static function findSimilarTasks(ProjectTask $task): ?array { // 使用 AI 模块的 Embedding 搜索 - $searchText = $task->name . ' ' . ($task->content ?? ''); + $searchText = $task->name; + if (empty($searchText)) { + return null; + } try { $result = AI::getEmbedding($searchText); @@ -179,12 +181,12 @@ class AiTaskSuggestion /** * 转义用户输入以防止 Prompt 注入 */ - private static function escapeUserInput(string $input): string + private static function escapeUserInput(string $input, int $length = 500): string { // 移除可能影响 AI Prompt 解析的特殊字符 $input = str_replace(['```', '---', '==='], '', $input); // 截断过长的输入 - return mb_substr(trim($input), 0, 500); + return mb_substr(trim($input), 0, $length); } /** @@ -192,32 +194,30 @@ class AiTaskSuggestion */ private static function buildDescriptionPrompt(ProjectTask $task): string { - $taskName = self::escapeUserInput($task->name); - $projectName = self::escapeUserInput($task->project->name ?? '未知项目'); + $taskName = self::escapeUserInput($task->name, 100); + $projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100); + $columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50); return <<name); + $taskName = self::escapeUserInput($task->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']); + $nickname = self::escapeUserInput($member['nickname'], 20); $membersText .= "- {$nickname}(ID:{$member['userid']})"; if (!empty($member['profession'])) { - $profession = self::escapeUserInput($member['profession']); + $profession = self::escapeUserInput($member['profession'], 50); $membersText .= ",职位:{$profession}"; } - $membersText .= ",进行中任务:{$member['in_progress_count']}个"; + $membersText .= ",进行中:{$member['in_progress_count']}个"; $membersText .= ",近期完成:{$member['completed_count']}个"; - if ($member['similar_count'] > 0) { - $membersText .= ",处理过类似任务:{$member['similar_count']}个"; - } $membersText .= "\n"; } - $taskName = self::escapeUserInput($task->name); - $taskContent = self::escapeUserInput($task->content ?? ''); - 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"; + $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"; } - $dismissUrl = "dootask://ai-dismiss/similar/{$taskId}/{$msgId}"; - return <<dialog_id)) { + if (empty($suggestions)) { return null; } + // 如果任务没有对话,自动创建 + if (!$task->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, @@ -630,72 +643,71 @@ MD; 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; - } + if (Base::isError($result)) { + return null; + } + $msgId = $result['data']->id ?? 0; + if (empty($msgId)) { + return null; } - 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): void + 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; + return Base::retError('消息不存在'); } $content = $msg->msg['text'] ?? ''; if (empty($content)) { - return; + return Base::retError('消息内容为空'); } - // 根据状态替换对应的按钮 - $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); + // 根据类型和参数构建匹配模式,添加 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, '/') . '"[^}]*)\}:::/'; } - // 更新消息 - WebSocketDialogMsg::sendMsg( + $newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content); + + // 更新消息并返回结果 + return WebSocketDialogMsg::sendMsg( 'change-' . $msgId, $dialogId, 'text', ['text' => $newContent, 'type' => 'md'], - self::AI_ASSISTANT_USERID, - true, // push_self + self::AI_ASSISTANT_USERID ); } } diff --git a/app/Tasks/AiTaskAnalyzeTask.php b/app/Tasks/AiTaskAnalyzeTask.php index 75a83d08c..e9185293a 100644 --- a/app/Tasks/AiTaskAnalyzeTask.php +++ b/app/Tasks/AiTaskAnalyzeTask.php @@ -5,7 +5,6 @@ namespace App\Tasks; use App\Models\ProjectTask; use App\Models\ProjectTaskAiEvent; use App\Module\AiTaskSuggestion; -use App\Module\Base; /** * AI 任务分析异步任务 @@ -24,7 +23,7 @@ class AiTaskAnalyzeTask extends AbstractTask public function start() { $task = ProjectTask::with('project')->find($this->taskId); - if (!$task || $task->deleted_at || !$task->dialog_id) { + if (!$task || $task->deleted_at) { return; } @@ -66,7 +65,9 @@ class AiTaskAnalyzeTask extends AbstractTask try { // 检查是否满足执行条件 - if (!AiTaskSuggestion::shouldExecute($task, $eventType)) { + $shouldExecute = AiTaskSuggestion::shouldExecute($task, $eventType); + + if (!$shouldExecute) { $event->markSkipped('不满足执行条件'); continue; } diff --git a/app/Tasks/AiTaskLoopTask.php b/app/Tasks/AiTaskLoopTask.php index 1421e6a04..8562b90c7 100644 --- a/app/Tasks/AiTaskLoopTask.php +++ b/app/Tasks/AiTaskLoopTask.php @@ -37,11 +37,6 @@ class AiTaskLoopTask extends AbstractTask return; } - // 检查功能开关 - if (Base::settingFind('aiAssistant', 'taskSuggestion') !== 'open') { - return; - } - // 查询待处理的任务 $tasks = $this->findPendingTasks(); @@ -72,7 +67,6 @@ class AiTaskLoopTask extends AbstractTask ->whereNull('archived_at') ->where('created_at', '<=', $delayTime) // 创建超过延迟时间 ->where('created_at', '>=', Carbon::now()->subDays(1)) // 只处理1天内的 - ->whereNotNull('dialog_id') // 有对话ID ->whereNotIn('id', $processedTaskIds) ->orderBy('created_at', 'asc') ->take(self::BATCH_SIZE) diff --git a/resources/assets/js/pages/manage/components/DialogMarkdown.vue b/resources/assets/js/pages/manage/components/DialogMarkdown.vue index e6f1b4009..d83dab2fd 100644 --- a/resources/assets/js/pages/manage/components/DialogMarkdown.vue +++ b/resources/assets/js/pages/manage/components/DialogMarkdown.vue @@ -172,12 +172,29 @@ export default { const [, type, taskId, msgId, queryString] = match; const params = new URLSearchParams(queryString || ''); - // 先调用接口标记为已采纳,获取建议数据 - this.$store.dispatch('applyAiSuggestion', { + // 构建请求数据 + const requestData = { task_id: parseInt(taskId, 10), msg_id: parseInt(msgId, 10), type, - }).then(({data}) => { + }; + + // assignee 类型传递 userid + if (type === 'assignee' && params.get('userid')) { + requestData.userid = parseInt(params.get('userid'), 10); + } + + // similar 类型传递 related + if (type === 'similar' && params.get('related')) { + requestData.related = parseInt(params.get('related'), 10); + } + + // 调用接口标记为已采纳 + this.$store.dispatch('applyAiSuggestion', requestData).then(({data}) => { + // 更新本地消息 + if (data.msg) { + this.$store.dispatch('saveDialogMsg', data.msg); + } // 根据类型调用对应的业务接口 this.applyAiSuggestionByType(data.type, data.task_id, data.result, params); }).catch(({msg}) => { @@ -208,15 +225,21 @@ export default { break; case 'assignee': - // 指派负责人 + // 增加负责人(保留现有负责人) const userid = params.get('userid'); if (!userid || isNaN(parseInt(userid, 10))) { $A.modalError(this.$L('请选择负责人')); return; } + const newUserId = parseInt(userid, 10); + // 从缓存获取任务当前负责人 + const task = this.$store.state.cacheTasks.find(t => t.id === taskId); + const currentOwners = task?.task_user?.filter(u => u.owner === 1).map(u => u.userid) || []; + // 追加新负责人(避免重复) + const owners = [...new Set([...currentOwners, newUserId])]; this.$store.dispatch('taskUpdate', { task_id: taskId, - owner: [parseInt(userid, 10)], + owner: owners, }).then(() => { $A.messageSuccess(this.$L('应用成功')); }).catch(({msg}) => { @@ -225,7 +248,7 @@ export default { break; case 'similar': - // 相似任务关联(当前功能未启用) + // 相似任务关联(后端已处理) $A.messageSuccess(this.$L('应用成功')); break; @@ -274,20 +297,37 @@ export default { /** * 处理 AI 建议忽略 - * 格式: dootask://ai-dismiss/{type}/{task_id}/{msg_id} + * 格式: dootask://ai-dismiss/{type}/{task_id}/{msg_id}?userid=xxx&related=xxx */ handleAiDismiss(href) { - const match = href.match(/^dootask:\/\/ai-dismiss\/(\w+)\/(\d+)\/(\d+)$/); + const match = href.match(/^dootask:\/\/ai-dismiss\/(\w+)\/(\d+)\/(\d+)(\?.*)?$/); if (!match) { return; } - const [, type, taskId, msgId] = match; + const [, type, taskId, msgId, queryString] = match; + const params = new URLSearchParams(queryString || ''); - this.$store.dispatch('dismissAiSuggestion', { + const data = { task_id: parseInt(taskId, 10), msg_id: parseInt(msgId, 10), type, - }).then(() => { + }; + + // assignee 类型传递 userid 用于单独忽略 + if (type === 'assignee' && params.get('userid')) { + data.userid = parseInt(params.get('userid'), 10); + } + + // similar 类型传递 related 用于单独忽略 + if (type === 'similar' && params.get('related')) { + data.related = parseInt(params.get('related'), 10); + } + + this.$store.dispatch('dismissAiSuggestion', data).then(({data: respData}) => { + // 更新本地消息 + if (respData.msg) { + this.$store.dispatch('saveDialogMsg', respData.msg); + } $A.messageSuccess(this.$L('已忽略')); }).catch(({msg}) => { $A.modalError(msg); diff --git a/resources/assets/js/utils/ai.js b/resources/assets/js/utils/ai.js index e2e9b0893..b0cacf7db 100644 --- a/resources/assets/js/utils/ai.js +++ b/resources/assets/js/utils/ai.js @@ -213,18 +213,17 @@ const AISystemConfig = { /** * 即时消息生成系统提示词 */ -const MESSAGE_AI_SYSTEM_PROMPT = `你是一名专业的沟通助手,协助用户编写得体、清晰且具行动指向的即时消息。 +const MESSAGE_AI_SYSTEM_PROMPT = `你是一名沟通助手,帮助用户编写即时消息。 写作要求: -1. 根据用户提供的需求与上下文生成完整消息,语气需符合业务沟通场景,保持真诚、礼貌且高效 -2. 默认使用简洁的短段落,可使用 Markdown 基础格式(加粗、列表、引用)增强结构,但不要输出代码块或 JSON -3. 如果上下文包含引用信息或草稿,请在消息中自然呼应相关要点 -4. 如无特别说明,将消息长度控制在 60-180 字;若需更短或更长,遵循用户描述 -5. 如需提出行动或问题,请明确表达,避免含糊 +1. 生成简短、得体的消息,语气符合业务沟通场景 +2. 可使用 Markdown 基础格式(加粗、列表),但不要输出代码块或 JSON +3. 如有引用内容或草稿,自然呼应相关要点 +4. 默认生成简短消息(一到几句话),仅在用户明确要求时才写长 输出规范: - 仅返回可直接发送的消息内容 -- 禁止在内容前后添加额外说明、标签或引导语`; +- 禁止添加额外说明或引导语`; /** * 任务生成系统提示词 @@ -237,7 +236,7 @@ const TASK_AI_SYSTEM_PROMPT = `你是一个专业的任务管理专家,擅长 3. 描述需覆盖任务背景、具体要求、交付标准、风险提示等关键信息 4. 描述内容使用Markdown格式,合理组织标题、列表、加粗等结构 5. 内容需适配项目管理系统,表述专业、逻辑清晰,并与用户输入语言保持一致 -6. 优先遵循用户在输入中给出的风格、长度或复杂度要求;默认情况下将详细描述控制在120-200字内,如用户要求简单或简短,则控制在80-120字内 +6. 根据任务复杂度灵活调整描述长度,简单任务保持简洁,复杂任务可适当展开 7. 当任务具有多个执行步骤、阶段或协作角色时,请拆解出 2-6 个关键子任务;如无必要,可返回空数组 8. 子任务应聚焦单一可执行动作,名称控制在8-30个字符内,避免重复和含糊表述 @@ -322,12 +321,12 @@ const REPORT_ANALYSIS_SYSTEM_PROMPT = `你是一名经验丰富的团队管理 2. 先给出整体概览,再列出具体亮点、风险或问题,以及明确的改进建议 3. 如有数据或目标,应评估其完成情况和后续跟进要点 4. 语气保持专业、客观、中立,不过度夸赞或批评 -5. 控制在 200-400 字之间,可视内容复杂度略微增减,但保持紧凑`; +5. 根据汇报内容的复杂度调整分析篇幅,保持紧凑、避免冗余`; /** * 智能搜索系统提示词 */ -const SEARCH_AI_SYSTEM_PROMPT = `你是一个智能搜索助手,负责帮助用户在 DooTask 系统中搜索和整理信息。 +const SEARCH_AI_SYSTEM_PROMPT = `你是一个智能搜索助手,帮助用户搜索和整理信息。 你可以使用 intelligent_search 工具来搜索任务、项目、文件和联系人。 请根据用户的搜索需求: diff --git a/resources/assets/js/utils/markdown.js b/resources/assets/js/utils/markdown.js index 34e0a2c76..c564237d4 100644 --- a/resources/assets/js/utils/markdown.js +++ b/resources/assets/js/utils/markdown.js @@ -10,6 +10,65 @@ const MarkdownUtils = { mdi: null, mds: null, + /** + * 处理 AI 建议操作按钮语法 + * 格式: :::ai-action{type="xxx" task="123" msg="456" userid="789" related="123" status="applied"}::: + * @param {string} text + * @returns {string} + */ + processAiAction: (text) => { + // 匹配 :::ai-action{...}::: 语法 + return text.replace(/:::ai-action\{([^}]+)\}:::/g, (match, attrs) => { + // 解析属性 + const params = {}; + attrs.replace(/(\w+)="([^"]+)"/g, (m, key, value) => { + params[key] = value; + }); + + const type = params.type || ''; + const status = params.status || ''; + + // 如果有 status,显示状态文字 + if (status) { + const statusLabels = { + description: { applied: '✓ 已采纳', dismissed: '✗ 已忽略' }, + subtasks: { applied: '✓ 已创建', dismissed: '✗ 已忽略' }, + assignee: { applied: '✓ 已指派', dismissed: '✗ 已忽略' }, + similar: { applied: '✓ 已关联', dismissed: '✗ 已忽略' }, + }; + const label = statusLabels[type]?.[status] || (status === 'applied' ? '✓ 已采纳' : '✗ 已忽略'); + const statusClass = status === 'applied' ? 'ai-status-applied' : 'ai-status-dismissed'; + return `${label}`; + } + + const taskId = params.task || ''; + const msgId = params.msg || ''; + const userid = params.userid || ''; + const related = params.related || ''; + + // 根据类型生成按钮文案 + const buttonLabels = { + description: ['采纳描述', '忽略'], + subtasks: ['创建子任务', '忽略'], + assignee: ['指派', '忽略'], + similar: ['关联', '忽略'], + }; + const [applyLabel, dismissLabel] = buttonLabels[type] || ['采纳', '忽略']; + + // 构建 URL 查询参数 + let queryParams = []; + if (userid) queryParams.push(`userid=${userid}`); + if (related) queryParams.push(`related=${related}`); + const queryString = queryParams.length > 0 ? '?' + queryParams.join('&') : ''; + + const applyUrl = `dootask://ai-apply/${type}/${taskId}/${msgId}${queryString}`; + const dismissUrl = `dootask://ai-dismiss/${type}/${taskId}/${msgId}${queryString}`; + + // 返回按钮 HTML + return `✓ ${applyLabel} ✗ ${dismissLabel}`; + }); + }, + /** * 解析Markdown * @param {*} text @@ -369,6 +428,7 @@ export function MarkdownConver(text) { } text = MarkdownPluginUtils.clearEmptyReasoning(text); text = mergeConsecutiveToolUse(text); + text = MarkdownUtils.processAiAction(text); text = MarkdownUtils.mdi.render(text); return MarkdownUtils.formatMsg(text) } diff --git a/resources/assets/sass/pages/components/dialog-markdown/markdown.less b/resources/assets/sass/pages/components/dialog-markdown/markdown.less index 5c04203b1..5016f81df 100644 --- a/resources/assets/sass/pages/components/dialog-markdown/markdown.less +++ b/resources/assets/sass/pages/components/dialog-markdown/markdown.less @@ -95,6 +95,33 @@ body { animation: blink-animate 1.2s infinite steps(1, start); } } + + .ai-action-buttons { + display: inline-flex; + gap: 4px; + + .ai-btn { + display: inline-flex; + align-items: center; + padding: 0 4px; + text-decoration: none; + cursor: pointer; + + &.ai-btn-apply { + color: #52c41a; + } + + &.ai-btn-dismiss { + color: #909399; + } + } + } + + .ai-status { + display: inline-block; + margin-left: 8px; + color: #909399; + } } .self {