From 3a2b7b1400eece018965b6aa32a7e14706f112aa Mon Sep 17 00:00:00 2001 From: kuaifan Date: Wed, 14 Jan 2026 16:34:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20AI=20=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E5=8D=A0=E4=BD=8D=E7=AC=A6=E4=B8=8E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PromptPlaceholder 模块,负责构建用户上下文和条件性提示块 - 用户上下文包含:基础信息、部门、同事印象、场景角色、任务列表 - 前端使用 {{SYSTEM_OPTIONAL_PROMPTS}} 占位符,后端统一替换为实际内容 - 重构 BotReceiveMsgTask 和 ai.js,复用 PromptPlaceholder 逻辑 - 任务列表支持智能排序:逾期优先 → 最近活跃 → 负责人优先 --- app/Module/AI.php | 6 + app/Module/PromptPlaceholder.php | 300 +++++++++++++++++++ app/Tasks/BotReceiveMsgTask.php | 16 +- resources/assets/js/components/SearchBox.vue | 4 +- resources/assets/js/utils/ai.js | 23 +- 5 files changed, 317 insertions(+), 32 deletions(-) create mode 100644 app/Module/PromptPlaceholder.php diff --git a/app/Module/AI.php b/app/Module/AI.php index 39eedb25d..bac4c29f1 100644 --- a/app/Module/AI.php +++ b/app/Module/AI.php @@ -3,6 +3,7 @@ namespace App\Module; use App\Models\Setting; +use App\Models\User; use Cache; use Carbon\Carbon; @@ -169,6 +170,11 @@ class AI if ($role === '' || $message === '') { continue; } + // 替换系统条件性提示块占位符 + if (str_contains($message, '{{SYSTEM_OPTIONAL_PROMPTS}}')) { + $optionalPrompts = PromptPlaceholder::buildOptionalPrompts(User::userid()); + $message = str_replace('{{SYSTEM_OPTIONAL_PROMPTS}}', $optionalPrompts, $message); + } $context[] = [$role, $message]; } diff --git a/app/Module/PromptPlaceholder.php b/app/Module/PromptPlaceholder.php new file mode 100644 index 000000000..7184fa2c7 --- /dev/null +++ b/app/Module/PromptPlaceholder.php @@ -0,0 +1,300 @@ + 0) { + $userContext = self::buildUserContext($userid, $dialog); + if ($userContext) { + $blocks[] = << + 以下是当前对话用户的背景信息,当需要了解用户身份、工作职责或任务情况时可参考: + + {$userContext} + + 注意:此上下文仅供参考,用于理解用户背景和提供个性化帮助。如果与当前对话无关,请忽略。 + + EOF; + } + } + + // 格式指南块 + $blocks[] = <<<'EOF' + + 当你的回答中包含 DooTask 系统资源(任务、项目、文件等)时,建议使用以下链接格式使其可点击: + - 任务: [任务名称](dootask://task/{task_id}/{parent_id}),其中 parent_id 为主任务ID,主任务时为 0 + - 项目: [项目名称](dootask://project/{project_id}) + - 文件: [文件名称](dootask://file/{file_id}) + - 联系人: [用户名](dootask://contact/{userid}) + - 消息: [消息预览](dootask://message/{dialog_id}/{msg_id}) + + 注意:此格式指南不影响正常对话,仅在涉及上述资源时参考。如果与当前对话无关,请忽略。 + + EOF; + + return implode("\n\n", $blocks); + } + + /** + * 构建完整用户上下文 + */ + private static function buildUserContext(int $userid, ?WebSocketDialog $dialog = null): string + { + $lines = []; + + // 基础信息 + $basicInfo = self::getUserBasicInfo($userid); + $nickname = $basicInfo['nickname'] ?? ''; + if ($nickname) { + $basicLine = "与您对话的用户:{$nickname}"; + if ($basicInfo['profession'] ?? '') { + $basicLine .= "({$basicInfo['profession']})"; + } + $lines[] = "{$basicLine}(user_id: {$userid})"; + } + + if ($basicInfo['department'] ?? '') { + $lines[] = "所属部门:{$basicInfo['department']}"; + } + + if ($basicInfo['introduction'] ?? '') { + $lines[] = "个人简介:{$basicInfo['introduction']}"; + } + + // 同事印象 + $tags = self::getUserTags($userid); + if ($tags) { + $lines[] = "同事印象:{$tags}"; + } + + // 场景角色 + if ($dialog) { + $role = self::getUserRole($userid, $dialog); + if ($role) { + $lines[] = $role; + } + } + + // 进行中任务 + $inProgressTasks = self::getInProgressTasks($userid); + if ($inProgressTasks) { + $lines[] = "\n进行中的任务:\n{$inProgressTasks}"; + } + + // 最近完成 + $completedTasks = self::getCompletedTasks($userid); + if ($completedTasks) { + $lines[] = "\n最近完成:\n{$completedTasks}"; + } + + return implode("\n", $lines); + } + + /** + * 获取用户基础信息 + */ + private static function getUserBasicInfo(int $userid): array + { + $user = User::find($userid); + if (!$user) { + return []; + } + + return [ + 'nickname' => $user->nickname ?: '', + 'profession' => $user->profession ?: '', + 'introduction' => $user->introduction ? mb_substr($user->introduction, 0, 100) : '', + 'department' => $user->getDepartmentName() ?: '', + ]; + } + + /** + * 获取用户标签 Top 5 + */ + private static function getUserTags(int $userid): string + { + $tags = UserTag::where('user_id', $userid) + ->withCount(['recognitions as recognition_total']) + ->orderByDesc('recognition_total') + ->orderBy('id') + ->take(5) + ->pluck('name') + ->toArray(); + + return implode('、', $tags); + } + + /** + * 获取用户在场景中的角色 + */ + private static function getUserRole(int $userid, WebSocketDialog $dialog): string + { + if ($dialog->type !== 'group') { + return ''; + } + + switch ($dialog->group_type) { + case 'project': + $project = Project::whereDialogId($dialog->id)->first(); + if ($project) { + $projectUser = ProjectUser::whereProjectId($project->id)->whereUserid($userid)->first(); + if ($projectUser?->owner) { + return '该用户是此项目的负责人'; + } + } + break; + + case 'task': + $task = ProjectTask::whereDialogId($dialog->id)->first(); + if ($task) { + $taskUser = ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->first(); + if ($taskUser) { + return $taskUser->owner ? '该用户是此任务的负责人' : '该用户是此任务的协助人'; + } + } + break; + + case 'department': + $department = UserDepartment::whereDialogId($dialog->id)->first(); + if ($department?->owner_userid === $userid) { + return '该用户是此部门的负责人'; + } + break; + } + + return ''; + } + + /** + * 获取进行中的任务(缓存 3 分钟) + * + * 排序策略:逾期优先 → 最近活跃优先 → 负责人优先 → 高优先级优先 → 截止时间近优先 + */ + private static function getInProgressTasks(int $userid): string + { + $cacheKey = "prompt:tasks:in_progress:{$userid}"; + + return Cache::remember($cacheKey, 180, function () use ($userid) { + $now = Carbon::now(); + $threeDaysAgo = $now->copy()->subDays(3); + + // orderByRaw 中的表名需要带前缀 + $prefix = DB::getTablePrefix(); + $t = $prefix . 'project_tasks'; + $du = $prefix . 'web_socket_dialog_users'; + + $tasks = ProjectTask::query() + ->select([ + 'project_tasks.id', + 'project_tasks.name', + 'project_tasks.p_name', + 'project_tasks.end_at', + 'project_task_users.owner' + ]) + ->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id') + ->leftJoin('web_socket_dialog_users', function ($join) use ($userid) { + $join->on('project_tasks.dialog_id', '=', 'web_socket_dialog_users.dialog_id') + ->where('web_socket_dialog_users.userid', '=', $userid); + }) + ->where('project_task_users.userid', $userid) + ->where('project_tasks.visibility', 1) + ->whereNull('project_tasks.complete_at') + ->whereNull('project_tasks.archived_at') + ->whereNull('project_tasks.deleted_at') + ->orderByRaw("CASE WHEN {$t}.end_at IS NOT NULL AND {$t}.end_at < ? THEN 0 ELSE 1 END", [$now]) + ->orderByRaw("CASE WHEN {$du}.last_at >= ? THEN 0 ELSE 1 END", [$threeDaysAgo]) + ->orderByDesc('web_socket_dialog_users.last_at') + ->orderByDesc('project_task_users.owner') + ->orderByDesc('project_tasks.p_level') + ->orderByRaw("CASE WHEN {$t}.end_at IS NULL THEN 1 ELSE 0 END") + ->orderBy('project_tasks.end_at') + ->take(20) + ->get(); + + return self::formatTaskList($tasks, $now); + }); + } + + /** + * 获取最近完成的任务(缓存 3 分钟) + */ + private static function getCompletedTasks(int $userid): string + { + $cacheKey = "prompt:tasks:completed:{$userid}"; + + return Cache::remember($cacheKey, 180, function () use ($userid) { + $tasks = ProjectTask::query() + ->select([ + 'project_tasks.id', + 'project_tasks.name' + ]) + ->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id') + ->where('project_task_users.userid', $userid) + ->where('project_tasks.visibility', 1) + ->whereNotNull('project_tasks.complete_at') + ->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(7)) + ->whereNull('project_tasks.deleted_at') + ->orderByDesc('project_tasks.complete_at') + ->take(5) + ->get(); + + if ($tasks->isEmpty()) { + return ''; + } + + return $tasks->map(fn($task) => "- {$task->name} (task:{$task->id})")->implode("\n"); + }); + } + + /** + * 格式化任务列表 + */ + private static function formatTaskList($tasks, Carbon $now): string + { + if ($tasks->isEmpty()) { + return ''; + } + + return $tasks->map(function ($task) use ($now) { + $line = '- '; + if ($task->p_name) { + $line .= "[{$task->p_name}] "; + } + $line .= "{$task->name} (task_id:{$task->id})"; + if ($task->end_at && Carbon::parse($task->end_at)->lt($now)) { + $line .= ' ⚠️逾期'; + } + return $line; + })->implode("\n"); + } +} diff --git a/app/Tasks/BotReceiveMsgTask.php b/app/Tasks/BotReceiveMsgTask.php index 7aa221569..89ff91180 100644 --- a/app/Tasks/BotReceiveMsgTask.php +++ b/app/Tasks/BotReceiveMsgTask.php @@ -17,6 +17,7 @@ use App\Module\Base; use App\Module\Doo; use App\Module\Ihttp; use App\Module\TextExtractor; +use App\Module\PromptPlaceholder; use Carbon\Carbon; use Exception; use DB; @@ -675,19 +676,8 @@ class BotReceiveMsgTask extends AbstractTask $prompt[] = implode("\n", $contextLines); } - // 4、追加条件性格式指南(放在最后,优先级最低) - $prompt[] = <<<'EOF' - - 当你的回答中包含 DooTask 系统资源(任务、项目、文件等)时,建议使用以下链接格式使其可点击: - - 任务: [任务名称](dootask://task/{task_id}/{parent_id}),其中 parent_id 为主任务ID,主任务时为 0 - - 项目: [项目名称](dootask://project/{project_id}) - - 文件: [文件名称](dootask://file/{file_id}) - - 联系人: [用户名](dootask://contact/{userid}) - - 消息: [消息预览](dootask://message/{dialog_id}/{msg_id}) - - 注意:此格式指南不影响正常对话,仅在涉及上述资源时参考。如果与当前对话无关,请忽略。 - - EOF; + // 4. 条件性提示块(用户上下文 + 格式指南) + $prompt[] = PromptPlaceholder::buildOptionalPrompts($userid, $dialog); $extras['system_message'] = implode("\n----\n", array_filter($prompt)); } diff --git a/resources/assets/js/components/SearchBox.vue b/resources/assets/js/components/SearchBox.vue index 1858aeffa..b18999f99 100755 --- a/resources/assets/js/components/SearchBox.vue +++ b/resources/assets/js/components/SearchBox.vue @@ -94,7 +94,7 @@ import {mapState} from "vuex"; import emitter from "../store/events"; import transformEmojiToHtml from "../utils/emoji"; -import {SEARCH_AI_SYSTEM_PROMPT} from "../utils/ai"; +import {SEARCH_AI_SYSTEM_PROMPT, withLanguagePreferencePrompt} from "../utils/ai"; export default { name: 'SearchBox', @@ -595,7 +595,7 @@ export default { handleAISearchBeforeSend(context = []) { const prepared = [ - ['system', SEARCH_AI_SYSTEM_PROMPT] + ['system', withLanguagePreferencePrompt(SEARCH_AI_SYSTEM_PROMPT)] ]; if (context.length > 0) { diff --git a/resources/assets/js/utils/ai.js b/resources/assets/js/utils/ai.js index 5bdbd6c69..db332fbad 100644 --- a/resources/assets/js/utils/ai.js +++ b/resources/assets/js/utils/ai.js @@ -337,21 +337,10 @@ const SEARCH_AI_SYSTEM_PROMPT = `你是一个智能搜索助手,负责帮助 4. 如有需要,可以进行多次搜索以获取更全面的结果`; /** - * DooTask 资源格式指南(条件性提示词) - * 仅在 AI 返回 DooTask 资源时生效,不影响普通对话 + * 系统条件性提示块占位符 + * 后端会将此占位符替换为:用户上下文 + 资源格式指南 */ -const DOOTASK_RESOURCE_FORMAT_GUIDE = ` - -当你的回答中包含 DooTask 系统资源(任务、项目、文件等)时,建议使用以下链接格式使其可点击: -- 任务: [任务名称](dootask://task/{task_id}/{parent_id}),其中 parent_id 为主任务ID,主任务时为 0 -- 项目: [项目名称](dootask://project/{project_id}) -- 文件: [文件名称](dootask://file/{file_id}) -- 联系人: [用户名](dootask://contact/{userid}) -- 消息: [消息预览](dootask://message/{dialog_id}/{msg_id}) - -注意:此格式指南不影响正常对话,仅在涉及上述资源时参考。如果与当前对话无关,请忽略。 - -`.trim(); +const SYSTEM_OPTIONAL_PROMPTS_PLACEHOLDER = '{{SYSTEM_OPTIONAL_PROMPTS}}'; /** * 输出语言偏好提示 @@ -363,8 +352,8 @@ const LANGUAGE_PREFERENCE_PROMPT = (label) => `输出语言策略: - 仅当我明确指定其他语言时,才切换到该语言。`; /** - * 注入语言偏好提示与资源格式指南 - * 返回拼接后的完整提示词 + * 注入语言偏好提示与系统条件性提示块占位符 + * 返回拼接后的完整提示词(占位符由后端替换为实际内容) */ const withLanguagePreferencePrompt = (prompt) => { if (typeof prompt !== 'string' || !prompt) { @@ -374,7 +363,7 @@ const withLanguagePreferencePrompt = (prompt) => { if (!label) { return prompt; } - return `${prompt}\n\n${LANGUAGE_PREFERENCE_PROMPT(label)}\n\n${DOOTASK_RESOURCE_FORMAT_GUIDE}`; + return `${prompt}\n\n${LANGUAGE_PREFERENCE_PROMPT(label)}\n\n${SYSTEM_OPTIONAL_PROMPTS_PLACEHOLDER}`; }; /**