diff --git a/docs/ai-deeplink-plugin-spec.md b/docs/ai-deeplink-plugin-spec.md new file mode 100644 index 000000000..2868e8751 --- /dev/null +++ b/docs/ai-deeplink-plugin-spec.md @@ -0,0 +1,40 @@ +# AI 内联深链 · 插件侧改动规格(dootask-ai,独立仓库) + +> 本仓库(主程序)已完成:深链目录、前端渲染/执行、移除 driver.js 引导。 +> 本文是配套的 **AI 插件仓库** 改动规格——只有插件侧改完,AI 才会真正在回复里输出深链。 +> 在插件改动落地前,主程序侧深链链路可用 `window.__openDeepLink('setting_system')` 或手工构造回复文本验证。 + +## 背景 +原「带我去」分步引导(driver.js + `show_guide` MCP 工具)已下线。替代方案:AI 在回复正文里把"可定位的页面/面板"词包成 **标准 markdown 链接**,前端渲染成可点 chip,点击直达那一屏。 + +## 需要做的改动 + +### 1. 下线引导 +- 删除/注销 `show_guide` MCP 工具(工具定义 + 注册 + system prompt 里对它的说明)。 +- 删除 system prompt 里关于 ` ```ai-guide ` 围栏脚本(steps/target/pre_action)的全部指导。 + +### 2. 启用深链 +- 把主程序 `resources/ai-kb/_meta/page-links.yaml` 的目录(`id / title / aliases / description`)注入 system prompt。建议在 ingest ai-kb `_meta` 时一并读取,渲染成一张"可用深链 id 表"。 +- 在 prompt 中约定输出语法与硬约束: + + ``` + 当回答涉及"某功能/设置在哪、怎么去某页面"时,把页面/面板名写成 markdown 链接: + [显示文字](dootask://link/) + 规则: + - 只能取下表中的值(闭集,禁止臆造;不确定就不要加链接,写普通文字) + - 一个目的地在一段话里最多链一次,不要给同一名词重复加链接 + - 只链"页面/面板"这类导航目的地;不要给动作词(点击/保存/开启)加链接 + - 深链只把用户送到那一屏,页面内具体控件仍用文字说明 + ``` + +- 典型例子(供 few-shot): + - 问「在哪里设置端到端加密」→ 「……在 [系统设置](dootask://link/setting_system) 的消息相关里开启。」 + - 问「怎么改个人资料」→ 「打开 [个人设置](dootask://link/setting_personal) 即可修改。」 + +### 3. 性质 +- 与原「带我去」一样属**建议性**:AI 是否生成深链取决于是否遵循 prompt。 +- 前端已对**非法 id 做兜底**(渲染时退化为纯文字,绝不出现死链),故 prompt 约束 + 前端校验双保险。 + +## 与主程序的契约 +- id 闭集 = `page-links.yaml` 的 `links:` 键集合,当前 21 个;主程序 `deep-links.js` 与之逐一对应(CI 校验 `tests/deep-links-parity.mjs`)。 +- 新增/调整深链目的地时:先改主程序 `page-links.yaml` + `deep-links.js`(同一提交),再同步插件注入的 id 表。 diff --git a/package.json b/package.json index 67925cc24..df57d589c 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,5 @@ } } ], - "dependencies": { - "driver.js": "^1.4.0" - } + "dependencies": {} } diff --git a/resources/ai-kb/_meta/page-links.yaml b/resources/ai-kb/_meta/page-links.yaml new file mode 100644 index 000000000..c7987679a --- /dev/null +++ b/resources/ai-kb/_meta/page-links.yaml @@ -0,0 +1,124 @@ +# AI 回复内联深链目录(语义层,供 AI 选择) +# +# 用途: +# 1. 注入 AI system prompt:AI 把回复正文里"可定位的页面/面板"词包成深链 +# [显示文字](dootask://link/) —— id 只能取本目录值(闭集,硬约束) +# 2. 前端 resources/assets/js/components/AIAssistant/deep-links.js 是对应的「可执行映射」 +# (id → 路由);两份的 id 集合必须一致 +# +# 边界:本期仅收录"无需运行时 id 的纯导航目的地"(送到某页面/面板)。 +# - 打开具体任务/对话/项目(需 task_id 等运行时 id)不在此,由 execute_action 承担 +# - 项目内弹窗类(project_settings/member/flow 等需组件接线)二期再加 + +version: 1 +last_updated: 2026-06-10 + +links: + + # ===== 顶层功能页 ===== + dashboard: + title: 仪表盘 + aliases: [仪表盘, 工作台, 首页] + description: 个人工作台首页(待办、项目概览) + + messenger: + title: 消息 + aliases: [消息, 聊天, 对话列表, 消息页] + description: 消息/聊天页面 + + calendar: + title: 日历 + aliases: [日历, 日程] + description: 日历日程页面 + + files: + title: 文件 + aliases: [文件, 文件管理, 我的文件, 网盘] + description: 文件管理页面 + + application: + title: 应用 + aliases: [应用, 应用中心, 微应用, 应用市场] + description: 应用/微应用中心 + + project_list: + title: 项目 + aliases: [项目, 项目列表, 所有项目] + description: 项目列表页(不定位到具体项目) + + # ===== 设置(独立路由)===== + setting_personal: + title: 个人设置 + aliases: [个人设置, 个人资料, 我的资料, 账号设置] + description: 个人资料设置 + + setting_checkin: + title: 签到设置 + aliases: [签到, 签到设置, 打卡设置] + description: 签到/打卡设置 + + setting_language: + title: 语言设置 + aliases: [语言, 语言设置, 切换语言] + description: 界面语言设置 + + setting_theme: + title: 主题设置 + aliases: [主题, 主题设置, 深色模式, 外观] + description: 主题/外观设置 + + setting_keyboard: + title: 快捷键设置 + aliases: [快捷键, 键盘快捷键] + description: 键盘快捷键设置 + + setting_license: + title: 授权许可 + aliases: [授权, 许可证, license] + description: 授权许可信息 + + setting_password: + title: 密码设置 + aliases: [密码, 修改密码, 改密码] + description: 登录密码修改 + + setting_email: + title: 邮箱设置 + aliases: [邮箱, 修改邮箱, 绑定邮箱] + description: 邮箱修改/绑定 + + setting_system: + title: 系统设置 + aliases: [系统设置, 后台设置, 全局设置, 消息相关, 端到端加密] + description: 系统设置主页(含消息相关、端到端加密等,仅管理员) + + setting_device: + title: 登录设备 + aliases: [登录设备, 设备管理, 在线设备] + description: 登录设备管理 + + setting_version: + title: 更新日志 + aliases: [更新日志, 版本, 版本信息, 关于] + description: 版本更新日志 + + setting_delete: + title: 注销账号 + aliases: [注销, 注销账号, 删除账号] + description: 账号注销 + + # ===== 系统设置内二级 Tab ===== + setting_system_task_priority: + title: 任务优先级 + aliases: [任务优先级, 优先级设置] + description: 系统设置 → 任务优先级 + + setting_system_column_template: + title: 项目模板 + aliases: [项目模板, 列模板, 看板模板] + description: 系统设置 → 项目模板 + + setting_system_file: + title: 文件设置 + aliases: [文件设置, 系统文件设置] + description: 系统设置 → 文件设置 diff --git a/resources/ai-kb/_meta/tool-binding.yaml b/resources/ai-kb/_meta/tool-binding.yaml index 565cbddd3..158f9f13a 100644 --- a/resources/ai-kb/_meta/tool-binding.yaml +++ b/resources/ai-kb/_meta/tool-binding.yaml @@ -186,10 +186,8 @@ tools: related_features: [ai-assistant] typical_chunk_types: [howto] - show_guide: - description: 在用户页面启动分步操作引导(高亮元素+解说气泡,上一步/下一步推进) - related_features: [ai-assistant] - typical_chunk_types: [howto, concept] + # 注:原 show_guide(driver.js 分步引导)已下线,改为 AI 回复内联深链 + # [显示文字](dootask://link/),目录见 _meta/page-links.yaml(前端渲染,非 MCP 工具) # ===== 内置工具(AI 插件 helper/tools.py)===== get_session_image: diff --git a/resources/ai-kb/zh/concept/ai-assistant/guide.md b/resources/ai-kb/zh/concept/ai-assistant/guide.md index f842cab86..3809ea28f 100644 --- a/resources/ai-kb/zh/concept/ai-assistant/guide.md +++ b/resources/ai-kb/zh/concept/ai-assistant/guide.md @@ -1,43 +1,40 @@ --- id: ai-assistant.guide.concept -title: 什么是页面操作引导 +title: 什么是页面深链(带我去) type: concept feature: ai-assistant scope: end-user locale: zh aliases: - 操作引导 - - 分步引导 - 带我去 - - 新手引导 -related_tools: [show_guide] + - 带我操作 + - 页面深链 + - 快捷跳转 +related_tools: [] related_pages: [] prerequisites: - 应用市场已安装 ai 插件 negative: - - 引导是解说式的,推进只靠气泡里的按钮,引导期间页面其他区域不可点击 - - 引导进度不保存,刷新页面或切换路由即结束 + - 深链只负责把你送到对应页面/面板,不会自动帮你点击页面内的具体按钮或开关 + - 需要运行时定位的目标(某条具体任务/对话)不通过深链,由 AI 直接帮你打开 last_verified: v1.7.90 --- -# 什么是页面操作引导 +# 什么是页面深链(带我去) ## 这是什么 -页面操作引导是 AI 助手的可视化教学能力:AI 回答"X 怎么操作"后,可以在页面上分步高亮相关按钮/输入框,并配解说气泡,像导购一样带着你走完整个流程。 +当你问 AI「X 在哪里设置 / 怎么去 X」时,AI 的回答里会把可定位的页面或面板(如「系统设置」「个人设置」「日历」)渲染成**蓝色可点击的链接**。点一下就**直接跳转到那一屏**,省去自己翻菜单。 -## 两种启动方式 -1. **「带我去」按钮**:AI 回答操作类问题时,回复末尾可能出现「带我去」按钮,点击即启动引导 -2. **直接说**:对 AI 说"带我去操作"、"演示一下",AI 会直接在页面上启动引导 +## 怎么用 +- 在 AI 助手浮窗里问操作类/“在哪”类问题 +- 回答正文里出现蓝色链接词,点击即跳转(AI 浮窗会自动收起,避免遮挡) +- 到达目标页后,按回答里的文字说明完成剩下的操作 -## 引导界面 -- 半透明遮罩 + 高亮框圈出当前步骤的目标元素,自动滚动到可见位置 -- 气泡显示步骤说明、当前进度(如 2 / 5)和「上一步 / 下一步 / 跳过引导」按钮 -- 找不到目标元素时(页面改版等),该步骤降级为纯文字说明,引导不中断 -- 手机等窄屏设备上气泡固定在屏幕底部 - -## 不支持 -- 不支持让用户亲手点击目标元素来推进步骤(需要的点击由引导代为执行) -- 不支持中断后恢复,跳过或刷新后需重新点「带我去」 +## 边界 +- 深链把你送到**正确的页面/面板**;页面内具体那一行开关/按钮在哪,需要你按说明自己看一眼 +- 不是每个名词都可点:只有命中系统内已登记目的地的词才会变成链接,其余按普通文字显示 +- 打开某条具体任务/对话(需要具体 ID)不走深链,AI 会直接帮你打开 ## 相关 - 如何使用:[[ai-assistant.start-guide.howto]] diff --git a/resources/ai-kb/zh/howto/ai-assistant/start-guide.md b/resources/ai-kb/zh/howto/ai-assistant/start-guide.md index 5b5492252..04d17a6a4 100644 --- a/resources/ai-kb/zh/howto/ai-assistant/start-guide.md +++ b/resources/ai-kb/zh/howto/ai-assistant/start-guide.md @@ -1,6 +1,6 @@ --- id: ai-assistant.start-guide.howto -title: 让 AI 带我操作(启动页面引导) +title: 让 AI 带我去对应页面(页面深链) type: howto feature: ai-assistant scope: end-user @@ -8,42 +8,36 @@ locale: zh aliases: - 带我去 - 带我操作 - - 一步步教我 - - 演示给我看 - - 怎么启动引导 -related_tools: [show_guide] + - 在哪里设置 + - 怎么去 + - 快捷跳转 +related_tools: [] related_pages: [] prerequisites: - 应用市场已安装 ai 插件 negative: - - 通过聊天对话 @AI 机器人暂不支持页面引导,需在 AI 助手浮窗中使用 + - 通过聊天对话 @AI 机器人暂不支持页面深链,需在 AI 助手浮窗中使用 last_verified: v1.7.90 --- -# 让 AI 带我操作(启动页面引导) +# 让 AI 带我去对应页面(页面深链) -## 方式一:点「带我去」按钮 +## 怎么做 1. 打开 AI 助手浮窗(右下角悬浮按钮) -2. 问一个操作类问题,例如"项目里怎么创建任务" -3. AI 回答后,若回复末尾出现「带我去」按钮,点击即开始分步引导 +2. 问一个“在哪/怎么去”类问题,例如「在哪里设置端到端加密」「个人资料怎么改」 +3. AI 回答里把可跳转的页面/面板渲染成**蓝色链接**,点击即直达那一屏 -## 方式二:直接让 AI 启动 -在 AI 助手浮窗中直接说: +## 例子 +- 问「在哪里设置端到端加密」→ 回答里「系统设置」是蓝色链接,点它直达系统设置页 +- 问「怎么改个人资料」→ 点「个人设置」直达个人设置 -- "带我去创建一个任务" -- "演示一下怎么改看板列名" -- "一步步教我提交审批" +## 到达之后 +深链把你送到对应页面/面板,页面内的具体开关/按钮请按回答里的文字说明自己操作一下即可。 -AI 会立即在页面上启动引导(浮窗自动收起,避免遮挡)。 - -## 引导中的操作 -- **下一步 / 上一步**:在气泡里点击按钮推进或回看 -- **跳过引导**:随时点气泡左下角的「跳过引导」结束 -- 需要切换页面的步骤由引导自动跳转,无需手动操作 - -## 出现「未找到目标元素」怎么办 -说明页面布局有变化或元素还没加载出来,该步骤会以纯文字说明显示,可以继续下一步;也可以跳过后把问题反馈给 AI(点踩 👎)。 +## 链接没出现 / 不可点怎么办 +- 不是每个名词都会变成链接,只有命中系统已登记目的地的词才可点,其余是普通文字——可换个更明确的问法(如直接问「系统设置在哪」) +- 若回答说“文档未说明”,多为知识库未覆盖该功能,可点踩 👎 反馈 ## 相关 -- 引导是什么:[[ai-assistant.guide.concept]] +- 深链是什么:[[ai-assistant.guide.concept]] - 回答不好怎么反馈:[[ai-assistant.feedback.howto]] diff --git a/resources/assets/js/components/AIAssistant/deep-links.js b/resources/assets/js/components/AIAssistant/deep-links.js new file mode 100644 index 000000000..c93d40501 --- /dev/null +++ b/resources/assets/js/components/AIAssistant/deep-links.js @@ -0,0 +1,82 @@ +/** + * AI 回复内联深链目录(可执行映射) + * + * AI 在回复正文里用标准 markdown 链接把"可定位的页面/面板"包成深链: + * [系统设置](dootask://link/setting_system) + * 渲染时 markdown.js 用 isDeepLinkId 校验(非法 id 退化为纯文字,绝不渲染死链), + * 点击时 DialogMarkdown 调 openDeepLink 直接导航到那一屏。 + * + * 这里是「可执行映射」:id → 怎么打开。语义目录(id/title/aliases,供 AI 选择)在 + * resources/ai-kb/_meta/page-links.yaml —— 两份的 id 集合必须一致(CI 校验见 + * tests/.. 或 page-links 解析)。本期均为无需运行时 id 的纯导航目的地。 + */ + +// id → 打开方式。route 走 window.$A.goForward(与 action-executor.js 一致)。 +const LINKS = { + // 顶层功能页 + dashboard: { route: { name: 'manage-dashboard' } }, + messenger: { route: { name: 'manage-messenger' } }, + calendar: { route: { name: 'manage-calendar' } }, + files: { route: { name: 'manage-file' } }, + application: { route: { name: 'manage-application' } }, + project_list: { route: { name: 'manage-project' } }, + + // 设置(12 个 manage-setting-* 独立路由) + setting_personal: { route: { name: 'manage-setting-personal' } }, + setting_checkin: { route: { name: 'manage-setting-checkin' } }, + setting_language: { route: { name: 'manage-setting-language' } }, + setting_theme: { route: { name: 'manage-setting-theme' } }, + setting_keyboard: { route: { name: 'manage-setting-keyboard' } }, + setting_license: { route: { name: 'manage-setting-license' } }, + setting_password: { route: { name: 'manage-setting-password' } }, + setting_email: { route: { name: 'manage-setting-email' } }, + setting_system: { route: { name: 'manage-setting-system', query: { tab: 'setting' } } }, + setting_device: { route: { name: 'manage-setting-device' } }, + setting_version: { route: { name: 'manage-setting-version' } }, + setting_delete: { route: { name: 'manage-setting-delete' } }, + + // 系统设置内的二级 Tab(system.vue 据 query.tab 切换) + setting_system_task_priority: { route: { name: 'manage-setting-system', query: { tab: 'taskPriority' } } }, + setting_system_column_template: { route: { name: 'manage-setting-system', query: { tab: 'columnTemplate' } } }, + setting_system_file: { route: { name: 'manage-setting-system', query: { tab: 'fileSetting' } } }, +}; + +export const CATALOG_IDS = Object.keys(LINKS); + +/** + * 校验 id 是否在目录内(渲染与点击两端都用,防死链) + * @param {string} id + * @returns {boolean} + */ +export function isDeepLinkId(id) { + return Object.prototype.hasOwnProperty.call(LINKS, id); +} + +/** + * 打开深链目的地 + * @param {string} id 目录 id + * @returns {boolean} 是否成功派发导航 + */ +export function openDeepLink(id) { + const entry = LINKS[id]; + if (!entry) { + return false; + } + if (entry.route && typeof window !== 'undefined' && window.$A?.goForward) { + // 浅拷贝避免 goForward 内部改动污染目录 + window.$A.goForward({ + name: entry.route.name, + ...(entry.route.params ? { params: { ...entry.route.params } } : {}), + ...(entry.route.query ? { query: { ...entry.route.query } } : {}), + }); + return true; + } + return false; +} + +// 暴露调试钩子供 Playwright/E2E 验证(对标原 __startAiGuide) +if (typeof window !== 'undefined') { + window.__openDeepLink = (id) => openDeepLink(id); +} + +export default openDeepLink; diff --git a/resources/assets/js/components/AIAssistant/guide/guide-renderer.js b/resources/assets/js/components/AIAssistant/guide/guide-renderer.js deleted file mode 100644 index 0d108050f..000000000 --- a/resources/assets/js/components/AIAssistant/guide/guide-renderer.js +++ /dev/null @@ -1,487 +0,0 @@ -/** - * AI 页面引导渲染器(单例状态机) - * - * 渲染层用 driver.js(高亮元素默认可点击 + 稳定定位 + 平滑过渡); - * 编排层自研:脚本 schema、四级元素定位、跨页 pre_action 导航、找不到时降级。 - * - * 两个入口汇聚到这里: - * - 通道A:AI 回复中的 ```ai-guide 围栏脚本 → DialogMarkdown「带我去」按钮点击 - * - 通道B:AI 调 show_guide MCP 工具 → operation-module WebSocket 请求 - * - * 元素定位四级 fallback: - * L1 selector(精确 CSS)→ L2 text(可访问名称匹配)→ L3 query(向量语义匹配) - * → L4 降级为居中纯文字气泡(不中断引导) - */ - -import { driver } from 'driver.js'; -import 'driver.js/dist/driver.css'; -import './guide.css'; -import { createActionExecutor } from '../action-executor'; -import { - collectElements, - isElementVisible, - findElementByRef, - searchByVector, -} from '../page-context-collector'; -import emitter from '../../../store/events'; - -// L3 向量匹配单次超时 -const VECTOR_TIMEOUT = 3000; -// pre_action 后等待页面响应的固定延迟 -const PRE_ACTION_DELAY = 300; -// pre_action 引发的路由变化宽限期 -const NAV_GRACE_MS = 3000; -// 目标元素未显式指定 wait 时的默认等待窗口(导航/弹窗后晚渲染兜底) -const DEFAULT_WAIT = 1500; - -const state = { - active: false, - script: null, - stepIndex: 0, - store: null, - router: null, - driver: null, - executor: null, - removeRouteHook: null, - navGraceUntil: 0, - // 自增运行序号:异步步骤解析期间引导被关闭/重启时丢弃旧结果 - runSeq: 0, -}; - -function escapeHtml(s) { - return (s || '').replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); -} - -/** - * 校验并归一引导脚本(宽容解析:未知字段忽略、缺 content 的步骤剔除) - * @returns {{valid: boolean, script?: Object, error?: string}} - */ -export function validateScript(raw) { - let script = raw; - if (typeof raw === 'string') { - try { - script = JSON.parse(raw); - } catch (e) { - return { valid: false, error: 'JSON 解析失败' }; - } - } - if (!script || typeof script !== 'object') { - return { valid: false, error: '脚本不是对象' }; - } - if (script.version !== 1) { - return { valid: false, error: `不支持的脚本版本: ${script.version}` }; - } - if (!Array.isArray(script.steps)) { - return { valid: false, error: '缺少 steps 数组' }; - } - const steps = script.steps - .filter(s => s && typeof s === 'object' && typeof s.content === 'string' && s.content.trim()) - .map(s => ({ - title: typeof s.title === 'string' ? s.title : '', - content: s.content.trim(), - pre_action: s.pre_action && typeof s.pre_action === 'object' ? s.pre_action : null, - target: s.target && typeof s.target === 'object' ? s.target : null, - placement: typeof s.placement === 'string' ? s.placement : 'auto', - })); - if (!steps.length) { - return { valid: false, error: '没有有效步骤' }; - } - return { - valid: true, - script: { - version: 1, - title: typeof script.title === 'string' ? script.title : '', - steps, - }, - }; -} - -/** - * 启动引导。脚本无效时抛 Error(operation-module 据此回传 AI)。 - */ -export function startGuide(raw, { store, router }) { - const check = validateScript(raw); - if (!check.valid) { - throw new Error(`引导脚本无效: ${check.error}`); - } - if (state.active) { - stopGuide({ silent: true }); - } - - state.active = true; - state.script = check.script; - state.stepIndex = 0; - state.store = store; - state.router = router; - state.executor = createActionExecutor(store, router); - state.runSeq++; - - // 通知 AI 浮窗收起,避免遮挡目标元素 - emitter.emit('aiGuideStarted'); - - createDriver(); - watchRoute(); - // 首步直接展示(不自动执行 pre_action):需要跳转的动作等用户点「下一步」再执行 - goToStep(0); - return { total_steps: check.script.steps.length }; -} - -/** - * 结束引导 - */ -export function stopGuide({ silent = false } = {}) { - if (!state.active) { - return; - } - state.active = false; - state.runSeq++; - if (state.removeRouteHook) { - state.removeRouteHook(); - state.removeRouteHook = null; - } - if (state.driver) { - try { - state.driver.destroy(); - } catch (e) { - // ignore - } - state.driver = null; - } - state.script = null; - state.executor = null; - if (!silent) { - $A.messageSuccess('引导已结束'); - } -} - -function createDriver() { - // 深色模式:DooTask 给 html 加全局 invert 滤镜,遮罩用浅色经反相后才呈暗色 - const dark = typeof document !== 'undefined' && document.body.classList.contains('dark-mode-reverse'); - state.driver = driver({ - allowClose: false, // 不允许点遮罩误关,提供显式「跳过引导」 - overlayColor: dark ? 'rgb(220, 220, 220)' : 'rgb(0, 0, 0)', - overlayOpacity: 0.5, - stagePadding: 6, - stageRadius: 6, - smoothScroll: true, - animate: true, - popoverClass: 'ai-guide-popover', - // disableActiveInteraction 默认 false → 高亮元素可点击(可点 + 下一步并存) - }); -} - -/** - * 加载态:解析/导航期间显示居中提示 - */ -function showLoading() { - if (!state.driver) { - return; - } - state.driver.highlight({ - popover: { - description: `
${escapeHtml($A.L('正在生成操作引导…'))}
`, - showButtons: [], - popoverClass: 'ai-guide-popover', - }, - }); -} - -function mapSide(placement) { - if (placement === 'top' || placement === 'bottom' || placement === 'left' || placement === 'right') { - return placement; - } - return undefined; // auto/center 交给 driver 自适应 -} - -/** - * 渲染某一步到 driver 气泡(el 为空 → 居中纯文字) - */ -function renderStep(index, el, degraded) { - if (!state.driver) { - return; - } - const total = state.script.steps.length; - const step = state.script.steps[index]; - const isLast = index + 1 >= total; - const prefix = degraded - ? `

${escapeHtml($A.L('未找到目标元素,以下为操作说明'))}

` - : ''; - - state.driver.highlight({ - element: el || undefined, - popover: { - title: step.title ? escapeHtml(step.title) : undefined, - description: prefix + escapeHtml(step.content), - side: mapSide(step.placement), - align: 'start', - showButtons: index > 0 ? ['next', 'previous'] : ['next'], - showProgress: total > 1, - progressText: `${index + 1} / ${total}`, - nextBtnText: isLast ? $A.L('完成') : $A.L('下一步'), - prevBtnText: $A.L('上一步'), - popoverClass: 'ai-guide-popover', - onNextClick: () => advance(), - onPrevClick: () => { - if (state.stepIndex > 0) { - goToStep(state.stepIndex - 1); - } - }, - onPopoverRender: (popover) => addSkipButton(popover), - }, - }); -} - -// 在气泡底部插入「跳过引导」按钮(driver 默认按钮区无此项) -function addSkipButton(popover) { - if (!popover || !popover.footerButtons) { - return; - } - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'ai-guide-skip-btn'; - btn.textContent = $A.L('跳过引导'); - btn.addEventListener('click', () => stopGuide()); - popover.footerButtons.insertBefore(btn, popover.footerButtons.firstChild); -} - -function watchRoute() { - if (!state.router || typeof state.router.afterEach !== 'function') { - return; - } - state.removeRouteHook = state.router.afterEach(() => { - // pre_action 自己发起的导航在宽限期内豁免 - if (!state.active || Date.now() < state.navGraceUntil) { - return; - } - stopGuide({ silent: true }); - $A.messageInfo('页面已切换,引导已结束'); - }); -} - -/** - * 点「下一步/完成」:先执行【当前步】的 pre_action(跳转/代点都在用户确认后才发生), - * 再进入下一步(leave-semantics)。最后一步执行完动作即结束。 - */ -async function advance() { - const seq = state.runSeq; - const cur = state.script.steps[state.stepIndex]; - const isLast = state.stepIndex + 1 >= state.script.steps.length; - - if (cur.pre_action) { - showLoading(); - try { - await runPreAction(cur.pre_action); - } catch (e) { - console.warn('[AIGuide] pre_action failed:', e); - $A.messageWarning(e?.message || '步骤执行失败'); - } - if (seq !== state.runSeq) return; - await delay(PRE_ACTION_DELAY); - if (seq !== state.runSeq) return; - } - - if (isLast) { - stopGuide(); - return; - } - goToStep(state.stepIndex + 1); -} - -/** - * 展示某一步:定位 target(不执行任何动作)。target 找不到 → 降级为纯文字。 - */ -async function goToStep(index) { - const seq = state.runSeq; - const step = state.script.steps[index]; - state.stepIndex = index; - showLoading(); - - let el = null; - let degraded = false; - try { - if (step.target) { - el = await resolveTarget(step.target); - if (seq !== state.runSeq) return; - if (!el) { - degraded = true; - } - } - } catch (e) { - if (seq !== state.runSeq) return; - console.warn('[AIGuide] resolve target failed, degrade to text:', e); - degraded = !!step.target; - el = null; - } - - renderStep(index, el, degraded); -} - -async function runPreAction(preAction) { - if (preAction.type === 'action' && preAction.name) { - state.navGraceUntil = Date.now() + NAV_GRACE_MS; - await state.executor.executeAction(preAction.name, preAction.params || {}); - return; - } - if (preAction.type === 'click' && preAction.target) { - const el = await resolveTarget(preAction.target); - if (!el) { - throw new Error('未找到要点击的元素'); - } - state.navGraceUntil = Date.now() + NAV_GRACE_MS; - el.click(); - } -} - -/** - * 四级元素定位;wait>0 时 MutationObserver 等待动态元素 - * @returns {Promise} - */ -async function resolveTarget(target) { - let el = resolveSync(target); - if (el) { - return el; - } - // L3 向量匹配(开销大,立即跑一次) - el = await resolveByVector(target); - if (el) { - return el; - } - // 默认给 1.5s 等待窗口:导航/弹窗后目标可能晚渲染,未显式指定也兜底重试 - const wait = Math.min(Math.max(parseInt(target.wait, 10) || DEFAULT_WAIT, 0), 15000); - if (!wait) { - return null; - } - // 等待动态元素:mutation 200ms 防抖只重跑廉价的 L1/L2,超时前最后跑一次 L3 - el = await new Promise(resolve => { - let timer = null; - let done = false; - const finish = (result) => { - if (done) return; - done = true; - observer.disconnect(); - clearTimeout(timeoutTimer); - clearTimeout(timer); - resolve(result); - }; - const observer = new MutationObserver(() => { - clearTimeout(timer); - timer = setTimeout(() => { - const found = resolveSync(target); - if (found) { - finish(found); - } - }, 200); - }); - observer.observe(document.body, { childList: true, subtree: true }); - const timeoutTimer = setTimeout(() => finish(null), wait); - }); - if (el) { - return el; - } - return resolveByVector(target); -} - -// 归一化文本,吸收常见近义动词差异(创建/新建/新增/添加),提升标签匹配命中率 -function _normText(s) { - return (s || '').toLowerCase().replace(/创建|新建|新增|添加/g, '建').replace(/\s+/g, ''); -} - -/** - * L1 selector → L2 text(同步、廉价) - */ -function resolveSync(target) { - // L1:精确 CSS 选择器 - if (target.selector) { - try { - const el = document.querySelector(target.selector); - if (el && isElementVisible(el)) { - return el; - } - } catch (e) { - // 选择器非法,忽略 - } - } - // L2:可访问名称匹配(精确 → 归一双向包含) - if (target.text) { - const { elements, refMap } = collectElements({ maxElements: 500 }); - const q = _normText(target.text); - const score = (info) => { - const name = _normText(info.name); - if (!name) return 0; - if (name === q) return 3; - if (name.includes(q)) return 2; - // 元素名较短且被 query 包含(如 query "创建项目卡片" 含元素 "新建项目") - if (name.length >= 2 && q.includes(name)) return 1; - return 0; - }; - let best = null; - let bestScore = 0; - for (const info of elements) { - const s = score(info); - if (s > bestScore) { - const el = findElementByRef(info.ref, refMap); - if (el && isElementVisible(el)) { - best = el; - bestScore = s; - if (s === 3) break; - } - } - } - if (best) { - return best; - } - } - return null; -} - -/** - * L3 query 向量语义匹配(走 assistant/match_elements,3s 超时) - */ -async function resolveByVector(target) { - if (!target.query || !state.store) { - return null; - } - try { - const { elements, refMap } = collectElements({ maxElements: 200 }); - if (!elements.length) { - return null; - } - const matches = await Promise.race([ - searchByVector(state.store, target.query, elements, 1), - delay(VECTOR_TIMEOUT).then(() => []), - ]); - if (matches && matches.length) { - const el = findElementByRef(matches[0].ref, refMap); - if (el && isElementVisible(el)) { - return el; - } - } - } catch (e) { - // 向量匹配失败静默降级 - } - return null; -} - -function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -export function isGuideActive() { - return state.active; -} - -export default startGuide; - -// 暴露到 window 供调试与 Playwright 测试使用 -if (typeof window !== 'undefined') { - window.__startAiGuide = (script, ctx = {}) => { - let { store, router } = ctx; - if (!store || !router) { - const root = document.getElementById('app')?.__vue__; - store = store || root?.$store; - router = router || root?.$router; - } - return startGuide(script, { store, router }); - }; - window.__stopAiGuide = () => stopGuide({ silent: true }); -} diff --git a/resources/assets/js/components/AIAssistant/guide/guide.css b/resources/assets/js/components/AIAssistant/guide/guide.css deleted file mode 100644 index 29ecad13a..000000000 --- a/resources/assets/js/components/AIAssistant/guide/guide.css +++ /dev/null @@ -1,47 +0,0 @@ -/* AI 页面引导:driver.js 气泡的项目内定制 - * 说明:DooTask 深色模式给 html 加全局 invert 滤镜,白底气泡/深色文字会自动反相, - * 故此处不单独写深色色值;遮罩颜色已在 guide-renderer.js 按深色切换。 */ - -.driver-popover.ai-guide-popover { - max-width: 340px; - border-radius: 10px; - font-size: 13px; -} - -.driver-popover.ai-guide-popover .driver-popover-title { - font-size: 14px; - font-weight: 600; -} - -.driver-popover.ai-guide-popover .driver-popover-description { - font-size: 13px; - line-height: 1.6; -} - -.driver-popover.ai-guide-popover .ai-guide-degraded { - margin: 0 0 6px 0; - color: #e6a23c; - font-size: 12px; -} - -.driver-popover.ai-guide-popover .ai-guide-loading { - padding: 6px 2px; - text-align: center; - color: #999; -} - -/* 跳过引导:弱化为左侧链接式按钮 */ -.driver-popover.ai-guide-popover .ai-guide-skip-btn { - margin-right: auto; - padding: 0 4px; - border: 0; - background: transparent; - color: #999; - font-size: 12px; - cursor: pointer; - text-shadow: none; -} - -.driver-popover.ai-guide-popover .ai-guide-skip-btn:hover { - color: #666; -} diff --git a/resources/assets/js/components/AIAssistant/index.vue b/resources/assets/js/components/AIAssistant/index.vue index d011970a4..df93c6437 100644 --- a/resources/assets/js/components/AIAssistant/index.vue +++ b/resources/assets/js/components/AIAssistant/index.vue @@ -359,7 +359,6 @@ export default { }, mounted() { emitter.on('openAIAssistant', this.onOpenAIAssistant); - emitter.on('aiGuideStarted', this.onGuideStarted); this.loadCachedModel(); this.loadInputHistory(); this.mountFloatButton(); @@ -367,7 +366,6 @@ export default { }, beforeDestroy() { emitter.off('openAIAssistant', this.onOpenAIAssistant); - emitter.off('aiGuideStarted', this.onGuideStarted); this.clearActiveSSEClients(); this.clearAutoSubmitTimer(); this.unmountFloatButton(); @@ -1509,12 +1507,6 @@ export default { /** * 页面引导启动时收起浮窗,避免遮挡目标元素 */ - onGuideStarted() { - if (this.showModal) { - this.closeAssistant(); - } - }, - /** * 滚动结果区域到底部 */ diff --git a/resources/assets/js/components/AIAssistant/operation-module.js b/resources/assets/js/components/AIAssistant/operation-module.js index 37bb9bea6..751ce072d 100644 --- a/resources/assets/js/components/AIAssistant/operation-module.js +++ b/resources/assets/js/components/AIAssistant/operation-module.js @@ -8,7 +8,6 @@ import { OperationClient } from './operation-client'; import { collectPageContext, searchByVector } from './page-context-collector'; import { createActionExecutor } from './action-executor'; -import { startGuide } from './guide/guide-renderer'; /** * 创建操作模块实例 @@ -107,23 +106,11 @@ class OperationModule { case 'execute_element_action': return this.executeElementAction(payload); - case 'show_guide': - return this.showGuide(payload); - default: throw new Error(`未知的操作类型: ${action}`); } } - /** - * 启动分步操作引导 - * 校验失败 throw(错误经 WS 回传给 AI);成功立即返回,不等引导走完(避免 requestTimeout) - */ - async showGuide(payload) { - const result = startGuide(payload, { store: this.store, router: this.router }); - return { success: true, total_steps: result.total_steps }; - } - /** * 获取页面上下文 */ diff --git a/resources/assets/js/pages/manage/components/DialogMarkdown.vue b/resources/assets/js/pages/manage/components/DialogMarkdown.vue index 51b16c12a..4576e8277 100644 --- a/resources/assets/js/pages/manage/components/DialogMarkdown.vue +++ b/resources/assets/js/pages/manage/components/DialogMarkdown.vue @@ -5,7 +5,7 @@ diff --git a/resources/assets/js/utils/markdown.js b/resources/assets/js/utils/markdown.js index f10a69c34..ca928552b 100644 --- a/resources/assets/js/utils/markdown.js +++ b/resources/assets/js/utils/markdown.js @@ -2,6 +2,7 @@ import MarkdownIt from "markdown-it"; import hljs from "highlight.js"; import mila from "markdown-it-link-attributes"; import mdKatex from "@traptitech/markdown-it-katex"; +import {isDeepLinkId} from "../components/AIAssistant/deep-links"; /** * Markdown @@ -70,26 +71,21 @@ const MarkdownUtils = { }, /** - * 渲染 ai-guide 围栏块(AI 回复中嵌入的页面引导脚本) - * JSON 合法 → 「带我去」按钮(脚本存 data-guide,点击由 DialogMarkdown 处理) - * JSON 不合法(流式中间态/畸形)→ 灰色占位,原文永不直出 - * @param {string} content 围栏内容 + * 处理 AI 回复中的内联深链(把"可定位的页面/面板"词渲染成可点 chip) + * 语法:[显示文字](dootask://link/), 取自深链目录(deep-links.js) + * 合法 id → 带 class 的锚点(点击由 DialogMarkdown 导航);非法 id → 退化为纯文字(绝不渲染死链) + * 在 markdown-it 渲染前预处理(与 processAiAction 同范式) + * @param {string} text * @returns {string} */ - renderAiGuide: (content) => { - try { - const script = JSON.parse(content); - if (script && script.version === 1 && Array.isArray(script.steps) && script.steps.length > 0) { - const escaped = typeof script.title === 'string' - ? script.title.replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])) - : ''; - const title = escaped ? `${escaped} · ` : ''; - return `${title}${$A.L('带我去')} →`; + processDeepLinks: (text) => { + return text.replace(/\[([^\]\n]+)\]\(dootask:\/\/link\/([a-z_]+)(?:\?[^)\s]*)?\)/g, (match, label, id) => { + if (!isDeepLinkId(id)) { + return label; } - } catch (e) { - // 流式未闭合/JSON 畸形,走占位 - } - return `${$A.L('正在生成操作引导…')}`; + const safeLabel = label.replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c])); + return `${safeLabel}`; + }); }, /** @@ -448,19 +444,11 @@ export function MarkdownConver(text) { MarkdownUtils.mdi.use(mila, {attrs: {target: '_blank', rel: 'noopener noreferrer'}}) MarkdownUtils.mdi.use(mdKatex, {blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000'}) MarkdownPluginUtils.initReasoningPlugin(MarkdownUtils.mdi); - // ai-guide 围栏分流:未闭合围栏 markdown-it 照常产出 fence token,天然覆盖流式中间态 - const defaultFence = MarkdownUtils.mdi.renderer.rules.fence - || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options)); - MarkdownUtils.mdi.renderer.rules.fence = (tokens, idx, options, env, self) => { - if (tokens[idx].info.trim() === 'ai-guide') { - return MarkdownUtils.renderAiGuide(tokens[idx].content); - } - return defaultFence(tokens, idx, options, env, self); - }; } text = MarkdownPluginUtils.clearEmptyReasoning(text); text = mergeConsecutiveToolUse(text); text = MarkdownUtils.processAiAction(text); + text = MarkdownUtils.processDeepLinks(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 1c64e3754..1960c6e5d 100644 --- a/resources/assets/sass/pages/components/dialog-markdown/markdown.less +++ b/resources/assets/sass/pages/components/dialog-markdown/markdown.less @@ -123,37 +123,23 @@ body { color: #909399; } - .ai-guide-block { - display: block; - margin: 8px 0; + // AI 回复内联深链 chip:把"系统设置"这类可定位词渲染成可点蓝色胶囊 + .ai-deeplink-chip { + display: inline; + padding: 1px 6px; + margin: 0 1px; + border-radius: 5px; + background: rgba(22, 119, 255, 0.08); + color: #1677ff; + text-decoration: none; + cursor: pointer; + white-space: nowrap; + transition: background 0.2s; - .ai-guide-btn { - display: inline-flex; - align-items: center; - padding: 6px 14px; - border-radius: 16px; - background: rgba(22, 119, 255, 0.08); - color: #1677ff; - text-decoration: none; - cursor: pointer; - font-size: 13px; - transition: background 0.2s; - - &:hover { - background: rgba(22, 119, 255, 0.15); - } + &:hover { + background: rgba(22, 119, 255, 0.16); } } - - .ai-guide-pending { - display: inline-block; - margin: 8px 0; - padding: 4px 10px; - border-radius: 6px; - background: rgba(0, 0, 0, 0.04); - color: #999; - font-size: 12px; - } } .self { diff --git a/tests/deep-links-parity.mjs b/tests/deep-links-parity.mjs new file mode 100644 index 000000000..930054dfd --- /dev/null +++ b/tests/deep-links-parity.mjs @@ -0,0 +1,43 @@ +/** + * 深链目录一致性校验: + * resources/assets/js/components/AIAssistant/deep-links.js 的可执行 id 集合 + * 必须与 resources/ai-kb/_meta/page-links.yaml 的语义 id 集合完全一致。 + * + * 用法:node tests/deep-links-parity.mjs (无依赖,纯正则解析) + * 不一致时打印差异并以非 0 退出,可接入 CI。 + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// page-links.yaml:links: 下两空格缩进的一级 key +const yaml = readFileSync(resolve(root, 'resources/ai-kb/_meta/page-links.yaml'), 'utf8'); +const yamlIds = new Set(); +let inLinks = false; +for (const line of yaml.split('\n')) { + if (/^links:\s*$/.test(line)) { inLinks = true; continue; } + if (inLinks && /^\S/.test(line)) { inLinks = false; } + const m = inLinks && line.match(/^ {2}([a-z_]+):\s*$/); + if (m) yamlIds.add(m[1]); +} + +// deep-links.js:LINKS 对象内四空格缩进、值为 { ... } 的 key +const js = readFileSync(resolve(root, 'resources/assets/js/components/AIAssistant/deep-links.js'), 'utf8'); +const jsIds = new Set(); +for (const m of js.matchAll(/^ {4}([a-z_]+):\s*\{/gm)) { + jsIds.add(m[1]); +} + +const onlyYaml = [...yamlIds].filter(id => !jsIds.has(id)); +const onlyJs = [...jsIds].filter(id => !yamlIds.has(id)); + +if (onlyYaml.length || onlyJs.length) { + console.error('✗ 深链目录不一致:'); + if (onlyYaml.length) console.error(' 仅在 page-links.yaml:', onlyYaml.join(', ')); + if (onlyJs.length) console.error(' 仅在 deep-links.js:', onlyJs.join(', ')); + process.exit(1); +} + +console.log(`✓ 深链目录一致,共 ${yamlIds.size} 个 id`);