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:
kuaifan 2026-06-10 23:55:09 +00:00
parent 4de6c69972
commit 39b9a72b16
16 changed files with 390 additions and 679 deletions

View 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 表。

View File

@ -92,7 +92,5 @@
}
}
],
"dependencies": {
"driver.js": "^1.4.0"
}
"dependencies": {}
}

View File

@ -0,0 +1,124 @@
# AI 回复内联深链目录(语义层,供 AI 选择)
#
# 用途:
# 1. 注入 AI system promptAI 把回复正文里"可定位的页面/面板"词包成深链
# [显示文字](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: 系统设置 → 文件设置

View File

@ -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_guidedriver.js 分步引导)已下线,改为 AI 回复内联深链
# [显示文字](dootask://link/<id>),目录见 _meta/page-links.yaml前端渲染非 MCP 工具)
# ===== 内置工具AI 插件 helper/tools.py=====
get_session_image:

View File

@ -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]]

View File

@ -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]]

View 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' } },
// 系统设置内的二级 Tabsystem.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;

View File

@ -1,487 +0,0 @@
/**
* AI 页面引导渲染器单例状态机
*
* 渲染层用 driver.js高亮元素默认可点击 + 稳定定位 + 平滑过渡
* 编排层自研脚本 schema四级元素定位跨页 pre_action 导航找不到时降级
*
* 两个入口汇聚到这里
* - 通道AAI 回复中的 ```ai-guide 围栏脚本 → DialogMarkdown「带我去」按钮点击
* - 通道BAI 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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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,
},
};
}
/**
* 启动引导脚本无效时抛 Erroroperation-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_elements3s 超时
*/
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 });
}

View File

@ -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;
}

View File

@ -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();
}
},
/**
* 滚动结果区域到底部
*/

View File

@ -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 };
}
/**
* 获取页面上下文
*/

View File

@ -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}

View File

@ -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>

View File

@ -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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[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)
}

View File

@ -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 {

View 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.yamllinks: 下两空格缩进的一级 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.jsLINKS 对象内四空格缩进、值为 { ... } 的 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`);