feat(ai-assistant): 页面操作穿透到当前微应用 iframe 内部

- 新增 active-context.js:按 microApps 解析最前的同源微应用 iframe(src 匹配 + contentDocument 同源探测),给出 doc/frameKey
- 采集器/执行器从只认主 document 泛化为按 el.ownerDocument 自适应,覆盖主文档与 iframe;视口与事件构造器取元素所在 window
- operation-module 加 scope(auto/main/app)、跨源/未就绪降级、frame 标注,并存活动上下文供失效守卫
- ai-kb 同步 page-action / page-context-tool / element-action 三 chunk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-12 07:02:35 +00:00
parent e74142e58d
commit 88fed0744c
7 changed files with 267 additions and 36 deletions

View File

@ -21,6 +21,7 @@ negative:
- AI 的页面操作仅在浏览器/桌面端会话窗口内生效,无法控制其他用户的页面
- 一次只能操作当前会话所在的页面,不能开新标签页
- 关闭浏览器或切到别的标签页时,页面操作会断连失败
- 跨源(外部站点)微应用 iframe 的内部不可操作,仅同源微应用插件可
last_verified: v1.7.90
---
@ -40,9 +41,12 @@ AI 助手通过高层导航和低层元素操作两类能力操作用户当前
## 受支持的低层元素动作
- `click``type``select``focus``scroll``hover`
## 微应用内部
当你打开了微应用插件(应用市场安装、反代到主站 `/apps/` 同源路径、以 iframe 呈现)并停在最前时,采集页面上下文与低层元素操作会**默认作用于该微应用内部**,可像操作主界面一样点按钮 / 填表 / 切菜单。多个微应用同时打开时只操作最前面那个切换或关闭应用后原先拿到的元素引用会失效AI 会被提示重新获取。跨源指向外部站点的微应用读不到内部AI 会提示改用数据命令或回到主界面。
## 不支持
- 不能模拟键盘组合键、不能拖拽
- 不能操作 iframe 内的内容
- 不能操作跨源(外部站点)微应用 iframe 的内部
- 不能跳转外部 URLgoForward 只走应用内路由)
- 不能伪造非用户主动触发的事件(如自动提交表单审批通过)

View File

@ -20,6 +20,7 @@ negative:
- 仅当用户在 AI 浮窗当前会话所在的浏览器/桌面端窗口时才能采集
- 不能跨标签 / 跨设备同步采集,每个 socket 只对应一个页面
- 不读取密码框/被遮挡元素/隐藏元素
- 跨源(外部站点)微应用 iframe 的内部无法采集,会提示改用数据命令
last_verified: v1.7.90
---
@ -35,11 +36,13 @@ last_verified: v1.7.90
- `total_count` / `has_more` / `offset`:分页
- `available_actions`:该页可用的高层动作(如项目页可 `open_task`
- `ref_map`ref → 定位信息
- `frame`:本次采集所在上下文——`scope``main` 主界面 / `app` 微应用)、`app_name`(微应用时的应用名)、`operable`(是否可操作)
## 调用模式
- **轻量**`interactive_only=true` + `max_elements=20`
- **完整**:默认前 100 个(含内容)
- **搜索**:传 `query` 先关键词后向量匹配
- **采集范围**:默认采集"用户最前面看到的"页面——有同源微应用插件在最前打开时采集其 iframe 内部,否则采集主界面;也可指定只采主界面。跨源微应用读不到内部,会返回 `operable:false` 并提示降级。
## 隐式触发
用户在浮窗里问"这个页面有什么操作"、"帮我点这页的某按钮"、"切到下一项目"时AI 都会先采集当前页面上下文再决定下一步。

View File

@ -28,7 +28,7 @@ last_verified: v1.7.90
# 让 AI 操作页面元素
## 这是什么
让 AI 助手在你当前页面上直接操作具体元素,包括点击按钮、输入文本、选下拉项、聚焦、滚动、悬停。常用于完成详细表单或触发某个隐藏在多级菜单里的功能。操作由 AI 助手在你的浏览器/桌面端页面上执行。
让 AI 助手在你当前页面上直接操作具体元素,包括点击按钮、输入文本、选下拉项、聚焦、滚动、悬停。常用于完成详细表单或触发某个隐藏在多级菜单里的功能。操作由 AI 助手在你的浏览器/桌面端页面上执行。打开了微应用插件(同源)时,这些操作会默认作用于最前那个微应用的内部。
## 怎么问
- "点击『保存』按钮"
@ -55,7 +55,7 @@ last_verified: v1.7.90
## 不支持的动作
- 不能模拟键盘按键、不能模拟组合键
- 不能拖拽元素drag/drop
- 不能操作 iframe 内的元素
- 不能操作跨源(外部站点)微应用 iframe 的内部元素(同源微应用插件内部可操作)
- 不能等到某个异步加载完成再点(无 wait 机制,需用户重新触发)
## 找不到元素怎么办

View File

@ -10,6 +10,7 @@
*/
import { findElementByRef } from './page-context-collector';
import { resolveActiveContext } from './active-context';
/**
* 创建操作执行器
@ -216,10 +217,30 @@ class ActionExecutor {
// ========== 元素级操作 ==========
/**
* 设置当前的 refMap operation-module 在获取上下文后调用
* 设置当前的 refMap 与活动上下文 operation-module 在获取上下文后调用
*/
setRefMap(refMap) {
setRefMap(refMap, context = null) {
this.currentRefMap = refMap;
this.currentContext = context;
}
/**
* 解析当前应执行元素操作的文档并做失效守卫校验
* 当上次采集发生在某个微应用 iframe 内时执行前重新解析最前上下文
* frameKey 不一致用户切了应用 / 重开过 / 刷新了页面则拒绝避免误操作
* @returns {Document}
*/
resolveContextDoc() {
const saved = this.currentContext;
// 无上下文信息或主文档:直接用主文档
if (!saved || saved.kind === 'main' || saved.frameKey === 'main') {
return document;
}
const now = resolveActiveContext(this.store, saved.scope || 'auto');
if (now.frameKey !== saved.frameKey || !now.reachable || !now.doc) {
throw new Error('页面上下文已变更(用户切换了应用或刷新了页面),请重新获取页面上下文后再操作');
}
return now.doc;
}
/**
@ -230,11 +251,15 @@ class ActionExecutor {
* @returns {Promise<Object>} 执行结果
*/
async executeElementAction(elementUid, action, value) {
const element = this.findElement(elementUid);
const doc = this.resolveContextDoc();
const element = this.findElement(elementUid, doc);
if (!element) {
throw new Error(`找不到元素: ${elementUid}`);
}
// 元素所在 window主文档或微应用 iframe事件需用它的构造器才被框架信任
const win = element.ownerDocument.defaultView || window;
switch (action) {
case 'click':
element.click();
@ -248,8 +273,8 @@ class ActionExecutor {
} else {
element.value = value || '';
}
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new win.Event('input', { bubbles: true }));
element.dispatchEvent(new win.Event('change', { bubbles: true }));
return { success: true, action: 'type', value, element: elementUid };
}
throw new Error('元素不支持输入操作');
@ -257,13 +282,13 @@ class ActionExecutor {
case 'select':
if (element.tagName === 'SELECT') {
element.value = value;
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new win.Event('change', { bubbles: true }));
return { success: true, action: 'select', value, element: elementUid };
}
// iView Select 组件 - 先点击打开下拉
element.click();
await this.delay(200);
const options = document.querySelectorAll('.ivu-select-dropdown-list .ivu-select-item');
const options = element.ownerDocument.querySelectorAll('.ivu-select-dropdown-list .ivu-select-item');
for (const option of options) {
if (option.textContent.trim().includes(value)) {
option.click();
@ -281,8 +306,8 @@ class ActionExecutor {
return { success: true, action: 'scroll', element: elementUid };
case 'hover':
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
element.dispatchEvent(new win.MouseEvent('mouseenter', { bubbles: true }));
element.dispatchEvent(new win.MouseEvent('mouseover', { bubbles: true }));
return { success: true, action: 'hover', element: elementUid };
default:
@ -294,7 +319,7 @@ class ActionExecutor {
* 查找元素
* 支持多种格式e1, @e1, ref=e1, CSS选择器
*/
findElement(identifier) {
findElement(identifier, doc = document) {
let ref = null;
if (identifier.startsWith('@')) {
ref = identifier.slice(1);
@ -306,13 +331,13 @@ class ActionExecutor {
// 如果是 ref 格式,使用 refMap 查找
if (ref && this.currentRefMap) {
const element = findElementByRef(ref, this.currentRefMap);
const element = findElementByRef(ref, this.currentRefMap, doc);
if (element) return element;
}
// 尝试作为 CSS 选择器
try {
const element = document.querySelector(identifier);
const element = doc.querySelector(identifier);
if (element) return element;
} catch (e) {
// 选择器无效,忽略

View File

@ -0,0 +1,145 @@
/**
* AI 助手活动上下文解析
*
* 页面操作采集 / 元素操作默认作用于"用户当前最前面看到的文档"
* 当有微应用插件以 iframe 形式打开并在最前时操作其 iframe 内部 DOM否则操作主文档
*
* 仅支持同源 iframe应用商店插件反代到主站同源路径contentDocument 可达
* 跨源或尚未就绪的应用返回 reachable=false由上层operation-module优雅降级
*
* micro-app@micro-zoe with 沙箱类型的子应用 DOM 本就渲染在主文档里
* 现采集器走主文档即可扫到这里按主界面处理不特殊穿透
*/
// iframe 元素选择器(见 MicroApps/iframe.vue
const APP_IFRAME_SELECTOR = 'iframe.micro-app-iframe-container';
/**
* 是否 iframe 类型微应用 MicroApps/index.vue::isIframe 对齐
* @param {string} type
* @returns {boolean}
*/
function isIframeType(type) {
return typeof type === 'string' && /^iframe/i.test(type);
}
/**
* 取最前打开的微应用isOpen lastOpenAt 最大
* @param {Object} store - Vuex store
* @returns {Object|null}
*/
function frontmostApp(store) {
const apps = (store?.state?.microApps || []).filter(a => a && a.isOpen);
if (!apps.length) {
return null;
}
return apps.reduce((a, b) => ((b.lastOpenAt || 0) > (a.lastOpenAt || 0) ? b : a));
}
/**
* 定位某微应用对应的 iframe DOM 元素
* @param {Object} app - microApps 中的 app 对象含注入运行时变量后的 url
* @returns {HTMLIFrameElement|null}
*/
function findAppIframe(app) {
if (!app || !app.url) {
return null;
}
const frames = [...document.querySelectorAll(APP_IFRAME_SELECTOR)];
if (!frames.length) {
return null;
}
// 1. 精确匹配 src === app.url
let hit = frames.find(f => f.src === app.url);
if (hit) {
return hit;
}
// 2. URL 规范化匹配(容忍尾斜杠/编码差异)
try {
const target = new URL(app.url, location.href).href;
hit = frames.find(f => {
try {
return new URL(f.src, location.href).href === target;
} catch (e) {
return false;
}
});
if (hit) {
return hit;
}
} catch (e) {
// ignore
}
// 3. 仅一个微应用 iframe 时直接用
return frames.length === 1 ? frames[0] : null;
}
/**
* 解析当前活动上下文
* @param {Object} store - Vuex store 实例
* @param {string} scope - 'auto'默认有同源微应用在最前就采它| 'main'强制主界面| 'app'强制最前微应用
* @returns {Object} 上下文
* { kind:'main', scope, doc:document, appName, label, reachable:true, frameKey:'main' }
* { kind:'app', scope:'app', doc:iframeDoc, appName, appTitle, label, reachable:true, frameKey }
* { kind:'app', scope:'app', appName, appTitle, reachable:false, reason:'cross_origin'|'not_ready'|'no_app', frameKey }
*/
export function resolveActiveContext(store, scope = 'auto') {
const mainCtx = {
kind: 'main',
scope: 'main',
doc: document,
appName: null,
label: '主界面',
reachable: true,
frameKey: 'main',
};
if (scope === 'main') {
return mainCtx;
}
const app = frontmostApp(store);
if (!app) {
// 显式要求 app 但没有打开任何微应用
if (scope === 'app') {
return {
kind: 'app', scope: 'app', appName: null, appTitle: '',
reachable: false, reason: 'no_app', frameKey: null,
};
}
// auto 下无微应用 → 主界面
return mainCtx;
}
// 非 iframe 类型micro-app with 沙箱DOM 在主文档,按主界面处理
if (!isIframeType(app.type)) {
return { ...mainCtx, appName: app.name, appTitle: app.title || app.name };
}
const base = {
kind: 'app',
scope: 'app',
appName: app.name,
appTitle: app.title || app.name,
frameKey: `app:${app.name}@${app.lastOpenAt || 0}`,
};
const iframe = findAppIframe(app);
if (!iframe) {
return { ...base, reachable: false, reason: 'not_ready' };
}
try {
const doc = iframe.contentDocument;
if (doc && doc.body && doc.body.children.length > 0) {
return { ...base, reachable: true, doc, label: `微应用「${base.appTitle}` };
}
return { ...base, reachable: false, reason: 'not_ready' };
} catch (e) {
// 跨源 iframecontentDocument 抛异常
return { ...base, reachable: false, reason: 'cross_origin' };
}
}
export default resolveActiveContext;

View File

@ -9,6 +9,7 @@
import { collectPageContext, searchByVector } from './page-context-collector';
import { createActionExecutor } from './action-executor';
import { resolveActiveContext } from './active-context';
/**
* 创建操作模块实例
@ -71,8 +72,39 @@ class OperationModule {
const query = payload?.query || '';
const offset = payload?.offset || 0;
const container = payload?.container || null;
const scope = payload?.scope || 'auto';
// 解析当前活动上下文:主界面,或最前打开的同源微应用 iframe
const active = resolveActiveContext(this.store, scope);
// 微应用打开但不可读(跨源 / 未就绪 / 指定 app 但无应用):优雅降级,不采集
if (active.kind === 'app' && !active.reachable) {
const reasonText = active.reason === 'cross_origin'
? '当前应用为跨源页面,无法读取其内部元素'
: active.reason === 'no_app'
? '当前没有打开任何微应用'
: '当前应用尚未加载完成';
const base = collectPageContext(this.store, { include_elements: false });
base.elements = [];
base.element_count = 0;
base.total_count = 0;
base.has_more = false;
base.frame = {
scope: 'app',
app_name: active.appName || null,
operable: false,
reachable: false,
reason: active.reason,
};
base.hint = `${reasonText}。可改用主界面scope=main操作或改用数据命令完成。`;
this.executor.setRefMap({}, null);
return base;
}
const doc = active.doc || document;
let context = collectPageContext(this.store, {
doc,
include_elements: includeElements,
interactive_only: interactiveOnly,
max_elements: maxElements,
@ -84,6 +116,7 @@ class OperationModule {
// 如果有 query 且关键词匹配失败,尝试向量搜索
if (query && !context.keyword_matched) {
const allContext = collectPageContext(this.store, {
doc,
include_elements: true,
interactive_only: interactiveOnly,
max_elements: 200,
@ -114,9 +147,16 @@ class OperationModule {
}
}
// 将 refMap 存储到 executor供后续元素操作使用
// 标注本次采集所在的上下文(主界面 / 微应用),让模型清楚在操作谁
context.frame = {
scope: active.scope,
app_name: active.appName || null,
operable: true,
};
// 将 refMap 与活动上下文一并存入 executor供后续元素操作使用含失效守卫
if (context.ref_map && this.executor) {
this.executor.setRefMap(context.ref_map);
this.executor.setRefMap(context.ref_map, active);
}
return context;

View File

@ -74,6 +74,7 @@ const ELEMENT_ROLE_MAP = {
*/
export function collectPageContext(store, options = {}) {
const routeName = store?.state?.routeName;
const doc = options.doc || document;
const includeElements = options.include_elements !== false;
const interactiveOnly = options.interactive_only || false;
const maxElements = options.max_elements || 50;
@ -84,8 +85,8 @@ export function collectPageContext(store, options = {}) {
// 基础上下文
const context = {
page_type: routeName || 'unknown',
page_url: window.location.href,
page_title: document.title,
page_url: (doc.location && doc.location.href) || window.location.href,
page_title: doc.title,
timestamp: Date.now(),
elements: [],
element_count: 0,
@ -98,6 +99,7 @@ export function collectPageContext(store, options = {}) {
// 收集可交互元素
if (includeElements) {
const result = collectElements({
doc,
interactiveOnly,
maxElements,
offset,
@ -236,6 +238,7 @@ function getAvailableActions(routeName, store) {
*/
export function collectElements(options = {}) {
const {
doc = document,
interactiveOnly = false,
maxElements = 50,
offset = 0,
@ -244,9 +247,9 @@ export function collectElements(options = {}) {
} = options;
// 确定查询的根元素
let rootElement = document;
let rootElement = doc;
if (container) {
rootElement = document.querySelector(container);
rootElement = doc.querySelector(container);
if (!rootElement) {
return { elements: [], refMap: {}, totalCount: 0, hasMore: false };
}
@ -337,7 +340,7 @@ export function collectElements(options = {}) {
if (processedElements.has(el)) continue;
// 检查是否有 cursor: pointer 样式
const computedStyle = window.getComputedStyle(el);
const computedStyle = (el.ownerDocument.defaultView || window).getComputedStyle(el);
if (computedStyle.cursor !== 'pointer') continue;
// 跳过不可见或禁用元素
@ -524,7 +527,7 @@ function getElementRole(el) {
// 检查是否可点击
if (el.onclick || el.hasAttribute('onclick') ||
el.style.cursor === 'pointer' ||
window.getComputedStyle(el).cursor === 'pointer') {
(el.ownerDocument.defaultView || window).getComputedStyle(el).cursor === 'pointer') {
return 'button';
}
@ -544,7 +547,7 @@ export function getElementName(el) {
const ariaLabelledBy = el.getAttribute('aria-labelledby');
if (ariaLabelledBy) {
const labelEl = document.getElementById(ariaLabelledBy);
const labelEl = el.ownerDocument.getElementById(ariaLabelledBy);
if (labelEl) {
return getTextContent(labelEl).substring(0, 100);
}
@ -552,7 +555,7 @@ export function getElementName(el) {
// 对于输入元素,查找关联的 label
if (el.id) {
const label = document.querySelector(`label[for="${el.id}"]`);
const label = el.ownerDocument.querySelector(`label[for="${el.id}"]`);
if (label) {
return getTextContent(label).substring(0, 100);
}
@ -600,8 +603,11 @@ function getTextContent(el) {
export function isElementVisible(el) {
if (!el) return false;
// 元素所在的 window主文档或微应用 iframe 文档),视口尺寸与样式都取它自己的
const win = el.ownerDocument.defaultView || window;
// 检查元素本身
const style = window.getComputedStyle(el);
const style = win.getComputedStyle(el);
if (style.display === 'none') return false;
if (style.visibility === 'hidden') return false;
@ -612,8 +618,8 @@ export function isElementVisible(el) {
if (rect.width === 0 && rect.height === 0) return false;
// 检查是否在视口内或附近(允许稍微超出)
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = win.innerHeight;
const viewportWidth = win.innerWidth;
// 元素完全在视口外
if (rect.bottom < -100 || rect.top > viewportHeight + 100) return false;
@ -622,7 +628,7 @@ export function isElementVisible(el) {
// 检查父元素的可见性
let parent = el.parentElement;
while (parent) {
const parentStyle = window.getComputedStyle(parent);
const parentStyle = win.getComputedStyle(parent);
if (parentStyle.display === 'none') return false;
if (parentStyle.visibility === 'hidden') return false;
parent = parent.parentElement;
@ -673,7 +679,7 @@ function generateSelector(el) {
let current = el;
let depth = 0;
while (current && current !== document.body && depth < 5) {
while (current && current !== el.ownerDocument.body && depth < 5) {
let selector = current.tagName.toLowerCase();
// 添加重要的类名(排除动态类)
@ -710,7 +716,7 @@ function generateSelector(el) {
* @param {Object} refMap - 引用映射表
* @returns {Element|null}
*/
export function findElementByRef(ref, refMap) {
export function findElementByRef(ref, refMap, doc = document) {
const refData = refMap[ref];
if (!refData) {
return null;
@ -718,7 +724,7 @@ export function findElementByRef(ref, refMap) {
// 首先尝试使用选择器 + name 双重匹配
if (refData.selector) {
const elements = document.querySelectorAll(refData.selector);
const elements = doc.querySelectorAll(refData.selector);
if (elements.length === 1) {
return elements[0];
@ -746,7 +752,7 @@ export function findElementByRef(ref, refMap) {
// 回退到角色+名称匹配
const roleSelector = `[role="${refData.role}"]`;
const candidates = document.querySelectorAll(roleSelector);
const candidates = doc.querySelectorAll(roleSelector);
for (const candidate of candidates) {
if (refData.name) {
@ -815,14 +821,22 @@ export default collectPageContext;
// 暴露到 window 供调试使用
if (typeof window !== 'undefined') {
window.__testPageContext = (options = {}) => {
// 简化版,不需要 store
// 简化版,不需要 store传 frameSrc 可在匹配的微应用 iframe 内采集(验证用)
let doc = document;
if (options.frameSrc) {
const f = [...document.querySelectorAll('iframe')].find(x => (x.src || '').includes(options.frameSrc));
if (f && f.contentDocument) {
doc = f.contentDocument;
}
}
const context = {
page_url: window.location.href,
page_title: document.title,
page_url: (doc.location && doc.location.href) || window.location.href,
page_title: doc.title,
timestamp: Date.now(),
};
const result = collectElements({
doc,
interactiveOnly: options.interactive_only || false,
maxElements: options.max_elements || 50,
offset: options.offset || 0,