From 22926e19cd5273b5aa442f83339ce5c5ac6de659 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Tue, 13 Jan 2026 10:31:31 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=20dootask://=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=A4=84=E7=90=86=E4=B8=8E=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 dootask:// 协议链接处理逻辑从 AIAssistant 迁移到 DialogMarkdown 组件 - 新增 beforeNavigate prop 支持导航前回调(如关闭弹窗) - 后端 BotReceiveMsgTask 添加条件性资源格式指南提示词 - 前端 ai.js 新增 SEARCH_AI_SYSTEM_PROMPT 和 DOOTASK_RESOURCE_FORMAT_GUIDE - SearchBox 改用统一的 SEARCH_AI_SYSTEM_PROMPT 常量 - 重构 ai.js 代码组织,添加注释说明各常量用途 --- app/Tasks/BotReceiveMsgTask.php | 14 ++ .../assets/js/components/AIAssistant.vue | 67 +------ resources/assets/js/components/SearchBox.vue | 28 +-- .../manage/components/DialogMarkdown.vue | 66 +++++++ resources/assets/js/utils/ai.js | 186 ++++++++++++------ 5 files changed, 212 insertions(+), 149 deletions(-) diff --git a/app/Tasks/BotReceiveMsgTask.php b/app/Tasks/BotReceiveMsgTask.php index b860a42a1..7aa221569 100644 --- a/app/Tasks/BotReceiveMsgTask.php +++ b/app/Tasks/BotReceiveMsgTask.php @@ -675,6 +675,20 @@ 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; + $extras['system_message'] = implode("\n----\n", array_filter($prompt)); } diff --git a/resources/assets/js/components/AIAssistant.vue b/resources/assets/js/components/AIAssistant.vue index 18c637682..50c8e3e35 100644 --- a/resources/assets/js/components/AIAssistant.vue +++ b/resources/assets/js/components/AIAssistant.vue @@ -84,7 +84,7 @@ v-if="response.rawOutput" class="ai-assistant-output-markdown no-dark-content" :text="response.displayOutput || response.rawOutput" - @click="onContentClick"/> + :before-navigate="() => { showModal = false }"/>
{{ response.status === 'error' ? (response.error || $L('发送失败')) : $L('等待 AI 回复...') }}
@@ -900,71 +900,6 @@ export default { return distance <= threshold; }, - /** - * 处理内容区域的点击事件 - */ - onContentClick(e) { - const target = e.target; - if (target.tagName !== 'A') { - return; - } - - const href = target.getAttribute('href'); - if (!href || !href.startsWith('dootask://')) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - // 解析 dootask:// 协议链接 - // 格式: dootask://type/id 或 dootask://type/id1/id2 例如 dootask://task/123 或 dootask://message/789/1234 - const match = href.match(/^dootask:\/\/(\w+)\/(\d+)(?:\/(\d+))?$/); - if (!match) { - return; - } - - const [, type, id, id2] = match; - const numId = parseInt(id, 10); - const numId2 = id2 ? parseInt(id2, 10) : null; - - switch (type) { - case 'task': - this.$store.dispatch('openTask', {id: (numId2 && numId2 > 0) ? numId2 : numId}); - break; - - case 'project': - this.showModal = false; - this.goForward({name: 'manage-project', params: {projectId: numId}}); - break; - - case 'file': - this.showModal = false; - this.goForward({name: 'manage-file', params: {folderId: 0, fileId: null, shakeId: numId}}); - this.$store.state.fileShakeId = numId; - setTimeout(() => { - this.$store.state.fileShakeId = 0; - }, 600); - break; - - case 'contact': - this.$store.dispatch('openDialogUserid', numId).catch(({msg}) => { - $A.modalError(msg || this.$L('打开会话失败')); - }); - break; - - case 'message': - this.$store.dispatch('openDialog', numId).then(() => { - if (numId2) { - this.$store.state.dialogSearchMsgId = numId2; - } - }).catch(({msg}) => { - $A.modalError(msg || this.$L('打开会话失败')); - }); - break; - } - }, - // ==================== 会话管理方法 ==================== /** diff --git a/resources/assets/js/components/SearchBox.vue b/resources/assets/js/components/SearchBox.vue index 23afaf403..1858aeffa 100755 --- a/resources/assets/js/components/SearchBox.vue +++ b/resources/assets/js/components/SearchBox.vue @@ -94,6 +94,7 @@ import {mapState} from "vuex"; import emitter from "../store/events"; import transformEmojiToHtml from "../utils/emoji"; +import {SEARCH_AI_SYSTEM_PROMPT} from "../utils/ai"; export default { name: 'SearchBox', @@ -593,33 +594,8 @@ export default { }, handleAISearchBeforeSend(context = []) { - const systemPrompt = [ - '你是一个智能搜索助手,负责帮助用户在 DooTask 系统中搜索和整理信息。', - '你可以使用 intelligent_search 工具来搜索任务、项目、文件和联系人。', - '', - '请根据用户的搜索需求:', - '1. 调用搜索工具获取相关结果', - '2. 对搜索结果进行分类整理', - '3. 以清晰的格式呈现给用户', - '4. 如有需要,可以进行多次搜索以获取更全面的结果', - '', - '## 链接格式要求', - '在返回结果时,请使用以下格式创建可点击的链接:', - '- 任务: [任务名称](dootask://task/任务ID/主任务ID)', - '- 项目: [项目名称](dootask://project/项目ID)', - '- 文件: [文件名称](dootask://file/文件ID)', - '- 联系人: [联系人名称](dootask://contact/用户ID)', - '- 消息: [消息内容预览](dootask://message/对话ID/消息ID)', - '', - '示例:', - '- [完成项目报告](dootask://task/123/0)(主任务)', - '- [编写测试用例](dootask://task/456/123)(子任务)', - '- [产品开发项目](dootask://project/456)', - '- [关于报销的讨论](dootask://message/789/1234)', - ].join('\n'); - const prepared = [ - ['system', systemPrompt] + ['system', SEARCH_AI_SYSTEM_PROMPT] ]; if (context.length > 0) { diff --git a/resources/assets/js/pages/manage/components/DialogMarkdown.vue b/resources/assets/js/pages/manage/components/DialogMarkdown.vue index 9a074c015..cee3e1034 100644 --- a/resources/assets/js/pages/manage/components/DialogMarkdown.vue +++ b/resources/assets/js/pages/manage/components/DialogMarkdown.vue @@ -13,6 +13,11 @@ export default { type: String, default: '' }, + // 导航前回调(如关闭弹窗) + beforeNavigate: { + type: Function, + default: null + }, }, data() { return { @@ -72,7 +77,68 @@ export default { }, onCLick(e) { + const target = e.target; + if (target.tagName === 'A') { + const href = target.getAttribute('href'); + if (href && href.startsWith('dootask://')) { + e.preventDefault(); + e.stopPropagation(); + this.handleDooTaskLink(href); + return; + } + } this.$emit('click', e) + }, + + /** + * 处理 dootask:// 协议链接 + * 格式: dootask://type/id 或 dootask://type/id1/id2 + */ + handleDooTaskLink(href) { + const match = href.match(/^dootask:\/\/(\w+)\/(\d+)(?:\/(\d+))?$/); + if (!match) { + return; + } + + const [, type, id, id2] = match; + const numId = parseInt(id, 10); + const numId2 = id2 ? parseInt(id2, 10) : null; + + switch (type) { + case 'task': + this.$store.dispatch('openTask', { id: (numId2 && numId2 > 0) ? numId2 : numId }); + break; + + case 'project': + this.beforeNavigate?.(); + this.goForward({ name: 'manage-project', params: { projectId: numId } }); + break; + + case 'file': + this.beforeNavigate?.(); + this.goForward({ name: 'manage-file', params: { folderId: 0, fileId: null, shakeId: numId } }); + this.$store.state.fileShakeId = numId; + setTimeout(() => { + this.$store.state.fileShakeId = 0; + }, 600); + break; + + case 'contact': + this.$store.dispatch('openDialogUserid', numId).catch(({ msg }) => { + $A.modalError(msg); + }); + break; + + case 'message': + this.$store.dispatch('openDialog', numId).then(() => { + if (numId2) { + this.$store.state.dialogSearchMsgId = numId2; + } + }).catch(({ msg }) => { + $A.modalError(msg); + }); + break; + } } } } diff --git a/resources/assets/js/utils/ai.js b/resources/assets/js/utils/ai.js index befbf0c49..5bdbd6c69 100644 --- a/resources/assets/js/utils/ai.js +++ b/resources/assets/js/utils/ai.js @@ -1,60 +1,8 @@ import {languageList, languageName} from "../language"; -const withLanguagePreferencePrompt = (prompt) => { - if (typeof prompt !== 'string' || !prompt) { - return prompt; - } - const label = languageList[languageName] || languageName || ''; - if (!label) { - return prompt; - } - return `${prompt}\n\n输出语言策略:\n- 默认使用 ${label} 输出。\n- 即使上下文或引用包含其他语言,也保持 ${label} 输出。\n- 仅当我明确指定其他语言时,才切换到该语言。`; -}; - -const AIModelNames = (str) => { - const lines = str.split('\n').filter(line => line.trim()); - - return lines.map(line => { - const [value, label] = line.split('|').map(s => s.trim()); - - return { - value, - label: label || value - }; - }, []).filter(item => item.value); -} - -const AINormalizeJsonContent = (content) => { - if (!content) { - return null; - } - const raw = String(content).trim(); - if (!raw) { - return null; - } - const candidates = [raw]; - const block = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); - if (block && block[1]) { - candidates.push(block[1].trim()); - } - const start = raw.indexOf('{'); - const end = raw.lastIndexOf('}'); - if (start !== -1 && end !== -1 && end > start) { - candidates.push(raw.slice(start, end + 1)); - } - for (const candidate of candidates) { - if (!candidate) { - continue; - } - try { - return JSON.parse(candidate); - } catch (e) { - // continue - } - } - return null; -} - +/** + * AI 服务商标识与显示名映射 + */ const AIBotMap = { openai: "ChatGPT", claude: "Claude", @@ -67,6 +15,9 @@ const AIBotMap = { wenxin: "文心一言", } +/** + * AI 系统配置表单与平台配置 + */ const AISystemConfig = { fields: [ { @@ -259,6 +210,9 @@ const AISystemConfig = { } } +/** + * 即时消息生成系统提示词 + */ const MESSAGE_AI_SYSTEM_PROMPT = `你是一名专业的沟通助手,协助用户编写得体、清晰且具行动指向的即时消息。 写作要求: @@ -272,6 +226,9 @@ const MESSAGE_AI_SYSTEM_PROMPT = `你是一名专业的沟通助手,协助用 - 仅返回可直接发送的消息内容 - 禁止在内容前后添加额外说明、标签或引导语`; +/** + * 任务生成系统提示词 + */ const TASK_AI_SYSTEM_PROMPT = `你是一个专业的任务管理专家,擅长将想法和需求转化为清晰、可执行的项目任务。 任务生成要求: @@ -315,6 +272,9 @@ const TASK_AI_SYSTEM_PROMPT = `你是一个专业的任务管理专家,擅长 - 如果涉及设计,要说明设计要求和期望效果 - 如果涉及测试,要明确测试范围和验收标准`; +/** + * 项目创建系统提示词 + */ const PROJECT_AI_SYSTEM_PROMPT = `你是一名资深的项目规划顾问,帮助团队快速搭建符合需求的项目。 生成要求: @@ -335,6 +295,9 @@ const PROJECT_AI_SYSTEM_PROMPT = `你是一名资深的项目规划顾问,帮 - 列表名称应当互不重复且语义明确 - 若上下文包含已有名称或列表,请在此基础上迭代优化`; +/** + * 周报/日报整理系统提示词 + */ const REPORT_AI_SYSTEM_PROMPT = `你是一名资深团队管理教练,需要根据提供的周报/日报草稿进行整理。 工作目标: @@ -349,6 +312,9 @@ const REPORT_AI_SYSTEM_PROMPT = `你是一名资深团队管理教练,需要 - 若原文包含数据或里程碑,保留并突出这些数字 - 若某一章节没有信息,请输出“暂无”而非留空`; +/** + * 汇报分析系统提示词 + */ const REPORT_ANALYSIS_SYSTEM_PROMPT = `你是一名经验丰富的团队管理顾问,擅长阅读和分析员工提交的工作汇报,能够快速提炼重点并给出可执行建议。 输出要求: @@ -358,9 +324,112 @@ const REPORT_ANALYSIS_SYSTEM_PROMPT = `你是一名经验丰富的团队管理 4. 语气保持专业、客观、中立,不过度夸赞或批评 5. 控制在 200-400 字之间,可视内容复杂度略微增减,但保持紧凑`; +/** + * 智能搜索系统提示词 + */ +const SEARCH_AI_SYSTEM_PROMPT = `你是一个智能搜索助手,负责帮助用户在 DooTask 系统中搜索和整理信息。 +你可以使用 intelligent_search 工具来搜索任务、项目、文件和联系人。 + +请根据用户的搜索需求: +1. 调用搜索工具获取相关结果 +2. 对搜索结果进行分类整理 +3. 以清晰的格式呈现给用户 +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(); + +/** + * 输出语言偏好提示 + * 用于引导 AI 按指定语言输出 + */ +const LANGUAGE_PREFERENCE_PROMPT = (label) => `输出语言策略: +- 默认使用 ${label} 输出。 +- 即使上下文或引用包含其他语言,也保持 ${label} 输出。 +- 仅当我明确指定其他语言时,才切换到该语言。`; + +/** + * 注入语言偏好提示与资源格式指南 + * 返回拼接后的完整提示词 + */ +const withLanguagePreferencePrompt = (prompt) => { + if (typeof prompt !== 'string' || !prompt) { + return prompt; + } + const label = languageList[languageName] || languageName || ''; + if (!label) { + return prompt; + } + return `${prompt}\n\n${LANGUAGE_PREFERENCE_PROMPT(label)}\n\n${DOOTASK_RESOURCE_FORMAT_GUIDE}`; +}; + +/** + * 解析模型列表文本为选项数组 + * 支持以 "|" 分隔显示名 + */ +const AIModelNames = (str) => { + const lines = str.split('\n').filter(line => line.trim()); + + return lines.map(line => { + const [value, label] = line.split('|').map(s => s.trim()); + + return { + value, + label: label || value + }; + }, []).filter(item => item.value); +} + +/** + * 尝试从内容中提取并解析 JSON + * 支持代码块与裸 JSON + */ +const AINormalizeJsonContent = (content) => { + if (!content) { + return null; + } + const raw = String(content).trim(); + if (!raw) { + return null; + } + const candidates = [raw]; + const block = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (block && block[1]) { + candidates.push(block[1].trim()); + } + const start = raw.indexOf('{'); + const end = raw.lastIndexOf('}'); + if (start !== -1 && end !== -1 && end > start) { + candidates.push(raw.slice(start, end + 1)); + } + for (const candidate of candidates) { + if (!candidate) { + continue; + } + try { + return JSON.parse(candidate); + } catch (e) { + // continue + } + } + return null; +} + export { - AIModelNames, - AINormalizeJsonContent, AIBotMap, AISystemConfig, MESSAGE_AI_SYSTEM_PROMPT, @@ -368,5 +437,8 @@ export { PROJECT_AI_SYSTEM_PROMPT, REPORT_AI_SYSTEM_PROMPT, REPORT_ANALYSIS_SYSTEM_PROMPT, + SEARCH_AI_SYSTEM_PROMPT, withLanguagePreferencePrompt, + AIModelNames, + AINormalizeJsonContent, }