mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-21 05:28:06 +00:00
refactor(ai-assistant): 用内联深链替代 driver.js 页面引导
「带我去」分步引导(driver.js + 四级元素定位 + show_guide)定位不稳、 二级菜单不可达、点高亮按钮触发跳转即被杀,体验差。改为 AI 回复正文里把 可定位的页面/面板渲染成可点深链 chip,点击直达那一屏。 - 新增深链目录:_meta/page-links.yaml(语义,供 AI 选择)+ deep-links.js (可执行映射,21 个无需运行时 id 的导航目的地),两端 id 一致性由 tests/deep-links-parity.mjs 校验 - markdown.js 加 processDeepLinks:[文字](dootask://link/<id>) 合法 id → chip,非法 id → 纯文字(绝不渲染死链);复用现有 dootask:// 点击拦截链路 - DialogMarkdown 加 link 分支调 openDeepLink;system.vue 支持 query.tab 初始化,系统设置二级 Tab 可深链直达 - 彻底移除 driver.js 引导:删 guide-renderer.js/guide.css、show_guide 前端通道、aiGuideStarted 监听、driver.js 依赖 - 同步 ai-kb:改写 guide/start-guide 两 chunk、tool-binding 去 show_guide - 插件侧 prompt 改动规格见 docs/ai-deeplink-plugin-spec.md(独立仓库实施) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4de6c69972
commit
39b9a72b16
40
docs/ai-deeplink-plugin-spec.md
Normal file
40
docs/ai-deeplink-plugin-spec.md
Normal file
@ -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/<id>)
|
||||
规则:
|
||||
- <id> 只能取下表中的值(闭集,禁止臆造;不确定就不要加链接,写普通文字)
|
||||
- 一个目的地在一段话里最多链一次,不要给同一名词重复加链接
|
||||
- 只链"页面/面板"这类导航目的地;不要给动作词(点击/保存/开启)加链接
|
||||
- 深链只把用户送到那一屏,页面内具体控件仍用文字说明
|
||||
```
|
||||
|
||||
- 典型例子(供 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 表。
|
||||
@ -92,7 +92,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"driver.js": "^1.4.0"
|
||||
}
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
124
resources/ai-kb/_meta/page-links.yaml
Normal file
124
resources/ai-kb/_meta/page-links.yaml
Normal file
@ -0,0 +1,124 @@
|
||||
# AI 回复内联深链目录(语义层,供 AI 选择)
|
||||
#
|
||||
# 用途:
|
||||
# 1. 注入 AI system prompt:AI 把回复正文里"可定位的页面/面板"词包成深链
|
||||
# [显示文字](dootask://link/<id>) —— 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: 系统设置 → 文件设置
|
||||
@ -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/<id>),目录见 _meta/page-links.yaml(前端渲染,非 MCP 工具)
|
||||
|
||||
# ===== 内置工具(AI 插件 helper/tools.py)=====
|
||||
get_session_image:
|
||||
|
||||
@ -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]]
|
||||
|
||||
@ -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]]
|
||||
|
||||
82
resources/assets/js/components/AIAssistant/deep-links.js
vendored
Normal file
82
resources/assets/js/components/AIAssistant/deep-links.js
vendored
Normal file
@ -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;
|
||||
@ -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: `<div class="ai-guide-loading">${escapeHtml($A.L('正在生成操作引导…'))}</div>`,
|
||||
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
|
||||
? `<p class="ai-guide-degraded">${escapeHtml($A.L('未找到目标元素,以下为操作说明'))}</p>`
|
||||
: '';
|
||||
|
||||
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<Element|null>}
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 滚动结果区域到底部
|
||||
*/
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面上下文
|
||||
*/
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<script>
|
||||
import '../../../../sass/pages/components/dialog-markdown/markdown.less'
|
||||
import {MarkdownConver} from "../../../utils/markdown";
|
||||
import {startGuide} from "../../../components/AIAssistant/guide/guide-renderer";
|
||||
import {isDeepLinkId, openDeepLink} from "../../../components/AIAssistant/deep-links";
|
||||
|
||||
export default {
|
||||
name: "DialogMarkdown",
|
||||
@ -79,22 +79,6 @@ export default {
|
||||
|
||||
onCLick(e) {
|
||||
const target = e.target;
|
||||
// AI 页面引导「带我去」按钮
|
||||
const guideBtn = target.closest?.('.ai-guide-btn');
|
||||
if (guideBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const raw = guideBtn.getAttribute('data-guide');
|
||||
if (raw) {
|
||||
try {
|
||||
this.beforeNavigate?.();
|
||||
startGuide(decodeURIComponent(raw), {store: this.$store, router: this.$router});
|
||||
} catch (err) {
|
||||
$A.messageError(err?.message || '操作引导启动失败');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target.tagName === 'A') {
|
||||
const href = target.getAttribute('href');
|
||||
if (href && href.startsWith('dootask://')) {
|
||||
@ -114,6 +98,11 @@ export default {
|
||||
* AI 建议链接: dootask://ai-apply/{type}/{task_id}/{msg_id} 或 dootask://ai-dismiss/...
|
||||
*/
|
||||
handleDooTaskLink(href) {
|
||||
// AI 回复内联深链:dootask://link/<id> → 导航到目录目的地
|
||||
if (href.startsWith('dootask://link/')) {
|
||||
this.handleDeepLink(href);
|
||||
return;
|
||||
}
|
||||
// 优先处理 AI 建议链接(格式与其他类型不同)
|
||||
if (href.startsWith('dootask://ai-apply/')) {
|
||||
this.handleAiApply(href);
|
||||
@ -177,6 +166,19 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理 AI 回复内联深链
|
||||
* 格式: dootask://link/<id>(id 取自深链目录 deep-links.js)
|
||||
*/
|
||||
handleDeepLink(href) {
|
||||
const match = href.match(/^dootask:\/\/link\/([a-z_]+)/);
|
||||
if (!match || !isDeepLinkId(match[1])) {
|
||||
return;
|
||||
}
|
||||
this.beforeNavigate?.();
|
||||
openDeepLink(match[1]);
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理 AI 建议采纳
|
||||
* 格式: dootask://ai-apply/{type}/{task_id}/{msg_id}?{params}
|
||||
|
||||
@ -23,12 +23,26 @@ import SystemTaskPriority from "./components/SystemTaskPriority";
|
||||
import SystemColumnTemplate from "./components/SystemColumnTemplate";
|
||||
import SystemFileSetting from "./components/SystemFileSetting";
|
||||
|
||||
const VALID_TABS = ['setting', 'taskPriority', 'columnTemplate', 'fileSetting'];
|
||||
|
||||
export default {
|
||||
components: {SystemColumnTemplate, SystemTaskPriority, SystemSetting, SystemFileSetting},
|
||||
data() {
|
||||
return {
|
||||
tabAction: 'setting',
|
||||
tabAction: this.tabFromRoute(),
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 支持深链直达指定分区:dootask://link/setting_system_xxx → query.tab
|
||||
'$route.query.tab'() {
|
||||
this.tabAction = this.tabFromRoute();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
tabFromRoute() {
|
||||
const tab = this.$route?.query?.tab;
|
||||
return VALID_TABS.includes(tab) ? tab : 'setting';
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
40
resources/assets/js/utils/markdown.js
vendored
40
resources/assets/js/utils/markdown.js
vendored
@ -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/<id>),<id> 取自深链目录(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 `<span class="ai-guide-block"><a href="javascript:;" class="ai-guide-btn" data-guide="${encodeURIComponent(content)}">${title}${$A.L('带我去')} →</a></span>`;
|
||||
processDeepLinks: (text) => {
|
||||
return text.replace(/\[([^\]\n]+)\]\(dootask:\/\/link\/([a-z_]+)(?:\?[^)\s]*)?\)/g, (match, label, id) => {
|
||||
if (!isDeepLinkId(id)) {
|
||||
return label;
|
||||
}
|
||||
} catch (e) {
|
||||
// 流式未闭合/JSON 畸形,走占位
|
||||
}
|
||||
return `<span class="ai-guide-pending">${$A.L('正在生成操作引导…')}</span>`;
|
||||
const safeLabel = label.replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||||
return `<a href="dootask://link/${id}" class="ai-deeplink-chip">${safeLabel}</a>`;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
43
tests/deep-links-parity.mjs
Normal file
43
tests/deep-links-parity.mjs
Normal file
@ -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`);
|
||||
Loading…
x
Reference in New Issue
Block a user