pi-to-chrome 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/console-buffer.ts +121 -0
- package/core/browser.ts +254 -0
- package/core/types.ts +29 -0
- package/index.ts +173 -0
- package/package.json +43 -0
- package/tool-registry.ts +48 -0
- package/tools/execute-js.ts +69 -0
- package/tools/find-elements.ts +337 -0
- package/tools/inspect-styles.ts +204 -0
- package/tools/read-console.ts +74 -0
- package/tools/take-screenshot.ts +89 -0
package/tool-registry.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-registry - Data-driven tool registration
|
|
3
|
+
*
|
|
4
|
+
* Replaces the ~120-line registerChromeTools() function with a declarative loop.
|
|
5
|
+
* New tools only need: add to ALL_TOOLS array.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import type { ToolDefinition } from './core/types';
|
|
10
|
+
import type { ConsoleBuffer } from './console-buffer';
|
|
11
|
+
import { ensureConnection, getActivePage } from './core/browser';
|
|
12
|
+
|
|
13
|
+
import { findElementsTool } from './tools/find-elements';
|
|
14
|
+
import { inspectStylesTool } from './tools/inspect-styles';
|
|
15
|
+
// import { takeScreenshotTool } from './tools/take-screenshot';
|
|
16
|
+
import { readConsoleTool } from './tools/read-console';
|
|
17
|
+
import { executeJsTool } from './tools/execute-js';
|
|
18
|
+
|
|
19
|
+
const ALL_TOOLS: ToolDefinition[] = [
|
|
20
|
+
findElementsTool,
|
|
21
|
+
inspectStylesTool,
|
|
22
|
+
// takeScreenshotTool,
|
|
23
|
+
readConsoleTool,
|
|
24
|
+
executeJsTool,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function registerTools(pi: ExtensionAPI, consoleBuffer: ConsoleBuffer): string[] {
|
|
28
|
+
const names: string[] = [];
|
|
29
|
+
|
|
30
|
+
for (const tool of ALL_TOOLS) {
|
|
31
|
+
pi.registerTool({
|
|
32
|
+
name: tool.name,
|
|
33
|
+
label: tool.label,
|
|
34
|
+
description: tool.description,
|
|
35
|
+
promptSnippet: tool.promptSnippet,
|
|
36
|
+
promptGuidelines: tool.promptGuidelines,
|
|
37
|
+
parameters: tool.parameters,
|
|
38
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
39
|
+
await ensureConnection();
|
|
40
|
+
const page = await getActivePage();
|
|
41
|
+
return tool.execute(page, params, { consoleBuffer });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
names.push(tool.name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return names;
|
|
48
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chrome_execute_js - Execute JavaScript in the page context
|
|
3
|
+
*
|
|
4
|
+
* Uses IIFE wrapper to support both expressions and statements.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ToolDefinition } from '../core/types';
|
|
8
|
+
import { Type } from '@sinclair/typebox';
|
|
9
|
+
|
|
10
|
+
export const executeJsTool: ToolDefinition<{ expression: string }> = {
|
|
11
|
+
name: 'chrome_execute_js',
|
|
12
|
+
label: 'Chrome Execute JS',
|
|
13
|
+
description: '在页面上下文中执行 JavaScript 表达式,用于查询 DOM 状态、获取计算样式、测量元素尺寸等。',
|
|
14
|
+
promptSnippet: '在页面上下文中执行 JavaScript',
|
|
15
|
+
promptGuidelines: [
|
|
16
|
+
'【获取精确数据的工具】当你需要元素的精确数值(offsetHeight、scrollHeight、getBoundingClientRect、scrollTop 等)时,用 chrome_execute_js 获取,不要猜测。',
|
|
17
|
+
'调试布局问题时的常用查询:检查 scrollHeight vs clientHeight(判断是否溢出)、遍历父容器链的 overflow/display/height(定位高度约束断裂点)。',
|
|
18
|
+
'可以用 JSON.stringify() 包裹返回值来获取可读输出。',
|
|
19
|
+
'注意:代码中不能使用 const/let 等块级声明语句,请用 var 或 IIFE 包裹。例:JSON.stringify((function(){ var x = 1; return x })())'
|
|
20
|
+
],
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
expression: Type.String({ description: 'JavaScript code to execute' })
|
|
23
|
+
}),
|
|
24
|
+
async execute(page, params) {
|
|
25
|
+
// Wrap in IIFE: expression-body for simple values, block-body for statements
|
|
26
|
+
// `(() => location.href)()` → expression, returns value directly
|
|
27
|
+
// `(() => { const x = 1; return x; })()` → block, supports return/multi-line
|
|
28
|
+
const trimmed = params.expression.trim();
|
|
29
|
+
// If it starts with { or a statement keyword, use block form
|
|
30
|
+
// 语句: return, const, let, var, if, for, while, try, switch, throw, function, class
|
|
31
|
+
const statementKeywords = ['return ', 'const ', 'let ', 'var ', 'if ', 'for ', 'while ', 'try ', 'switch ', 'throw ', 'function ', 'class ', 'async '];
|
|
32
|
+
const isBlock = trimmed.startsWith('{') || statementKeywords.some(kw => trimmed.startsWith(kw));
|
|
33
|
+
const wrappedCode = isBlock
|
|
34
|
+
? `(() => { ${params.expression} })()`
|
|
35
|
+
: `(() => ${params.expression})()`;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await page.evaluate(wrappedCode);
|
|
39
|
+
|
|
40
|
+
// Serialize result
|
|
41
|
+
let serializedResult: string;
|
|
42
|
+
let rawResult = result;
|
|
43
|
+
|
|
44
|
+
if (result === undefined) {
|
|
45
|
+
serializedResult = 'undefined';
|
|
46
|
+
} else if (result === null) {
|
|
47
|
+
serializedResult = 'null';
|
|
48
|
+
} else if (typeof result === 'string') {
|
|
49
|
+
serializedResult = result.length > 5000 ? result.slice(0, 5000) + '...' : result;
|
|
50
|
+
} else if (typeof result === 'number' || typeof result === 'boolean') {
|
|
51
|
+
serializedResult = String(result);
|
|
52
|
+
} else if (Array.isArray(result)) {
|
|
53
|
+
serializedResult = `Array(${result.length})`;
|
|
54
|
+
} else if (typeof result === 'object') {
|
|
55
|
+
serializedResult = 'Object';
|
|
56
|
+
} else {
|
|
57
|
+
serializedResult = JSON.stringify(result)?.slice(0, 5000) || String(result);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: 'text', text: serializedResult }],
|
|
62
|
+
details: { raw: rawResult }
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
} catch (error: any) {
|
|
66
|
+
throw new Error(error.message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chrome_find_elements v2 - Search for elements by text keywords
|
|
3
|
+
*
|
|
4
|
+
* New matching: Tier 1 (id/text-exact) > Tier 2 (class/attr) > Tier 3 (text-substr/tag)
|
|
5
|
+
* Deduplication keeps deepest descendants, discards ancestors.
|
|
6
|
+
* Selector is guaranteed unique via CSS path + nth-child fallback.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ToolDefinition } from '../core/types';
|
|
10
|
+
import { Type } from '@sinclair/typebox';
|
|
11
|
+
|
|
12
|
+
const DEBUG = false;
|
|
13
|
+
|
|
14
|
+
export const findElementsTool: ToolDefinition<{ text: string }> = {
|
|
15
|
+
name: 'chrome_find_elements',
|
|
16
|
+
label: 'Chrome Find Elements',
|
|
17
|
+
description: '搜索当前页面上的元素。关键词同时匹配文本、class、id、标签名等,智能排序返回最相关的结果。',
|
|
18
|
+
promptSnippet: '按关键词搜索页面元素',
|
|
19
|
+
promptGuidelines: [
|
|
20
|
+
'【定位元素的第一步】当你需要调试页面问题时,先用 chrome_find_elements 找到目标元素的 CSS selector,再用 chrome_inspect_styles 查看它的样式层叠链。',
|
|
21
|
+
'text 参数用 / 分隔中英文关键词,尽量多给变体。例:「灯泡」→ "灯泡/lamp/bulb/light"',
|
|
22
|
+
'拆成小词提高命中:「命令卡片列表」→ "命令卡片/命令/卡片/list/card/command"',
|
|
23
|
+
'返回的 selector 可直接用于 chrome_inspect_styles 和 chrome_execute_js。'
|
|
24
|
+
],
|
|
25
|
+
parameters: Type.Object({
|
|
26
|
+
text: Type.String({
|
|
27
|
+
description: '搜索关键词,/ 分隔多关键词(OR)。同时匹配文本、class、id、标签名等。'
|
|
28
|
+
})
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
async execute(page, params) {
|
|
32
|
+
if (!params.text || params.text.trim().length === 0) {
|
|
33
|
+
throw new Error('请提供搜索关键词');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const keywords = params.text
|
|
37
|
+
.split('/')
|
|
38
|
+
.map(k => k.trim().toLowerCase())
|
|
39
|
+
.filter(k => k.length > 0);
|
|
40
|
+
|
|
41
|
+
if (keywords.length === 0) {
|
|
42
|
+
throw new Error('请提供搜索关键词');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const results = await page.evaluate(
|
|
46
|
+
(keywords: string[], debug: boolean) => {
|
|
47
|
+
// ╔══════════════════════════════════════════════╗
|
|
48
|
+
// ║ Browser-side: no external vars, no imports ║
|
|
49
|
+
// ╚══════════════════════════════════════════════╝
|
|
50
|
+
|
|
51
|
+
// ─── Types (inline) ───────────────────────────
|
|
52
|
+
interface MatchResult {
|
|
53
|
+
tier: 1 | 2 | 3;
|
|
54
|
+
matchedBy: string;
|
|
55
|
+
matchedKeyword: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface MatchWithEl {
|
|
59
|
+
el: Element;
|
|
60
|
+
match: MatchResult;
|
|
61
|
+
area: number;
|
|
62
|
+
y: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface FindElementResult {
|
|
66
|
+
selector: string;
|
|
67
|
+
tag: string;
|
|
68
|
+
id: string | null;
|
|
69
|
+
classes: string[];
|
|
70
|
+
text: string;
|
|
71
|
+
ancestors: string;
|
|
72
|
+
rect: { x: number; y: number; w: number; h: number } | null;
|
|
73
|
+
_debug?: {
|
|
74
|
+
tier: number;
|
|
75
|
+
matchedBy: string;
|
|
76
|
+
matchedKeyword: string;
|
|
77
|
+
area: number;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── isVisible ─────────────────────────────────
|
|
82
|
+
function isVisible(el: Element): boolean {
|
|
83
|
+
if (el.tagName === 'BODY') return true;
|
|
84
|
+
if ((el as HTMLElement).offsetParent === null) return false;
|
|
85
|
+
const rect = el.getBoundingClientRect();
|
|
86
|
+
if (rect.width === 0 || rect.height === 0) return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── matchElement ──────────────────────────────
|
|
91
|
+
function matchElement(el: Element, keywords: string[]): MatchResult | null {
|
|
92
|
+
let bestTier = 99;
|
|
93
|
+
let matchedBy = '';
|
|
94
|
+
let matchedKeyword = '';
|
|
95
|
+
|
|
96
|
+
const textContent = (el.textContent || '').trim().toLowerCase();
|
|
97
|
+
const idLower = el.id.toLowerCase();
|
|
98
|
+
const tagLower = el.tagName.toLowerCase();
|
|
99
|
+
const classes = Array.from(el.classList);
|
|
100
|
+
const SKIP_ATTRS = ['aria-label', 'title', 'placeholder', 'alt'];
|
|
101
|
+
|
|
102
|
+
for (const keyword of keywords) {
|
|
103
|
+
// ── Tier 1: 精准 ──
|
|
104
|
+
if (idLower.includes(keyword)) {
|
|
105
|
+
if (1 < bestTier) {
|
|
106
|
+
bestTier = 1;
|
|
107
|
+
matchedBy = 'id:' + el.id;
|
|
108
|
+
matchedKeyword = keyword;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (textContent === keyword) {
|
|
112
|
+
if (1 < bestTier) {
|
|
113
|
+
bestTier = 1;
|
|
114
|
+
matchedBy = 'text-exact';
|
|
115
|
+
matchedKeyword = keyword;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Tier 2: 语义 ──
|
|
120
|
+
if (bestTier > 2) {
|
|
121
|
+
for (const cls of classes) {
|
|
122
|
+
if (cls.toLowerCase().includes(keyword)) {
|
|
123
|
+
if (2 < bestTier) {
|
|
124
|
+
bestTier = 2;
|
|
125
|
+
matchedBy = 'class:' + cls;
|
|
126
|
+
matchedKeyword = keyword;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (bestTier > 2) {
|
|
133
|
+
for (const attr of SKIP_ATTRS) {
|
|
134
|
+
const val = el.getAttribute(attr);
|
|
135
|
+
if (val && val.toLowerCase().includes(keyword)) {
|
|
136
|
+
if (2 < bestTier) {
|
|
137
|
+
bestTier = 2;
|
|
138
|
+
matchedBy = 'attr:' + attr;
|
|
139
|
+
matchedKeyword = keyword;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Tier 3: 模糊 ──
|
|
146
|
+
if (textContent.includes(keyword)) {
|
|
147
|
+
if (3 < bestTier) {
|
|
148
|
+
bestTier = 3;
|
|
149
|
+
matchedBy = 'text-substr';
|
|
150
|
+
matchedKeyword = keyword;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (tagLower === keyword) {
|
|
154
|
+
if (3 < bestTier) {
|
|
155
|
+
bestTier = 3;
|
|
156
|
+
matchedBy = 'tag:' + tagLower;
|
|
157
|
+
matchedKeyword = keyword;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return bestTier === 99 ? null : { tier: bestTier as 1 | 2 | 3, matchedBy, matchedKeyword };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── deduplicate ───────────────────────────────
|
|
166
|
+
function deduplicate(matches: MatchWithEl[]): MatchWithEl[] {
|
|
167
|
+
return matches.filter(m =>
|
|
168
|
+
!matches.some(n => n !== m && m.el.contains(n.el))
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── rank ──────────────────────────────────────
|
|
173
|
+
function rank(matches: MatchWithEl[]): void {
|
|
174
|
+
matches.sort((a, b) => {
|
|
175
|
+
const tierDiff = a.match.tier - b.match.tier;
|
|
176
|
+
if (tierDiff !== 0) return tierDiff;
|
|
177
|
+
if (a.area !== b.area) return a.area - b.area;
|
|
178
|
+
return a.y - b.y;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── buildAncestorPath ──────────────────────────
|
|
183
|
+
function buildAncestorPath(el: Element): string {
|
|
184
|
+
const parts: string[] = [];
|
|
185
|
+
let current = el.parentElement;
|
|
186
|
+
let count = 0;
|
|
187
|
+
while (current && current !== document.body && count < 5) {
|
|
188
|
+
let s = current.tagName.toLowerCase();
|
|
189
|
+
if (current.id) {
|
|
190
|
+
s += '#' + current.id;
|
|
191
|
+
}
|
|
192
|
+
const cls = Array.from(current.classList).slice(0, 3);
|
|
193
|
+
if (cls.length > 0) {
|
|
194
|
+
s += '.' + cls.join('.');
|
|
195
|
+
}
|
|
196
|
+
parts.push(s);
|
|
197
|
+
current = current.parentElement;
|
|
198
|
+
count++;
|
|
199
|
+
}
|
|
200
|
+
return parts.join(' > ');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── buildSelector ──────────────────────────────
|
|
204
|
+
function buildSelector(el: Element): string {
|
|
205
|
+
const tag = el.tagName.toLowerCase();
|
|
206
|
+
const allClasses = Array.from(el.classList);
|
|
207
|
+
|
|
208
|
+
// Strategy 1: id
|
|
209
|
+
if (el.id) {
|
|
210
|
+
const candidate = '#' + el.id;
|
|
211
|
+
if (document.querySelectorAll(candidate).length === 1) {
|
|
212
|
+
return candidate;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Strategy 2: tag + all classes
|
|
217
|
+
if (allClasses.length > 0) {
|
|
218
|
+
const candidate = tag + '.' + allClasses.join('.');
|
|
219
|
+
if (document.querySelectorAll(candidate).length === 1) {
|
|
220
|
+
return candidate;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Strategy 3: ancestor path
|
|
225
|
+
const path: string[] = [
|
|
226
|
+
tag + (allClasses.length > 0 ? '.' + allClasses.join('.') : '')
|
|
227
|
+
];
|
|
228
|
+
let current = el.parentElement;
|
|
229
|
+
while (current && current !== document.body) {
|
|
230
|
+
let level = current.tagName.toLowerCase();
|
|
231
|
+
if (current.id) {
|
|
232
|
+
level += '#' + current.id;
|
|
233
|
+
}
|
|
234
|
+
const cls = Array.from(current.classList);
|
|
235
|
+
if (cls.length > 0) {
|
|
236
|
+
level += '.' + cls.join('.');
|
|
237
|
+
}
|
|
238
|
+
path.unshift(level);
|
|
239
|
+
const candidate = path.join(' > ');
|
|
240
|
+
if (document.querySelectorAll(candidate).length === 1) {
|
|
241
|
+
return candidate;
|
|
242
|
+
}
|
|
243
|
+
current = current.parentElement;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Strategy 4: nth-child fallback
|
|
247
|
+
if (!el.parentElement) {
|
|
248
|
+
return tag;
|
|
249
|
+
}
|
|
250
|
+
const parentChildren = el.parentElement.children;
|
|
251
|
+
const index = Array.from(parentChildren).indexOf(el) + 1;
|
|
252
|
+
return path.join(' > ') + ':nth-child(' + index + ')';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── buildResult ───────────────────────────────
|
|
256
|
+
function buildResult(el: Element, matchInfo: MatchResult, debug: boolean): FindElementResult {
|
|
257
|
+
const rect = el.getBoundingClientRect();
|
|
258
|
+
const result: FindElementResult = {
|
|
259
|
+
selector: buildSelector(el),
|
|
260
|
+
tag: el.tagName.toLowerCase(),
|
|
261
|
+
id: el.id || null,
|
|
262
|
+
classes: Array.from(el.classList).slice(0, 10),
|
|
263
|
+
text: (el.textContent || '').trim().slice(0, 80),
|
|
264
|
+
ancestors: buildAncestorPath(el),
|
|
265
|
+
rect: {
|
|
266
|
+
x: Math.round(rect.x),
|
|
267
|
+
y: Math.round(rect.y),
|
|
268
|
+
w: Math.round(rect.width),
|
|
269
|
+
h: Math.round(rect.height)
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
if (debug) {
|
|
274
|
+
result._debug = {
|
|
275
|
+
tier: matchInfo.tier,
|
|
276
|
+
matchedBy: matchInfo.matchedBy,
|
|
277
|
+
matchedKeyword: matchInfo.matchedKeyword,
|
|
278
|
+
area: Math.round(rect.width * rect.height)
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── searchElements (main entry) ───────────────
|
|
286
|
+
function searchElements(keywords: string[]): FindElementResult[] {
|
|
287
|
+
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'HEAD', 'NOSCRIPT']);
|
|
288
|
+
const matches: MatchWithEl[] = [];
|
|
289
|
+
|
|
290
|
+
const walker = document.createTreeWalker(
|
|
291
|
+
document.body,
|
|
292
|
+
NodeFilter.SHOW_ELEMENT,
|
|
293
|
+
null
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
let node: Node | null = walker.currentNode;
|
|
297
|
+
while (node) {
|
|
298
|
+
const el = node as Element;
|
|
299
|
+
if (!SKIP_TAGS.has(el.tagName)) {
|
|
300
|
+
const match = matchElement(el, keywords);
|
|
301
|
+
if (match !== null && isVisible(el)) {
|
|
302
|
+
const rect = el.getBoundingClientRect();
|
|
303
|
+
matches.push({ el, match, area: rect.width * rect.height, y: rect.y });
|
|
304
|
+
// 截断保护:大页面避免后续 deduplicate O(n²) 过慢
|
|
305
|
+
if (matches.length > 500) break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
node = walker.nextNode();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 截断保护触发时,先粗排再截断,保留高质量结果
|
|
312
|
+
if (matches.length > 500) {
|
|
313
|
+
rank(matches);
|
|
314
|
+
matches.length = 500;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const deduped = deduplicate(matches);
|
|
318
|
+
rank(deduped);
|
|
319
|
+
|
|
320
|
+
const top15 = deduped.slice(0, 15);
|
|
321
|
+
return top15.map(m => buildResult(m.el, m.match, debug));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return searchElements(keywords);
|
|
325
|
+
},
|
|
326
|
+
keywords,
|
|
327
|
+
DEBUG
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const summary = `找到 ${results.length} 个匹配「${params.text}」的元素`;
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: 'text', text: summary }],
|
|
334
|
+
details: { total: results.length, results }
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chrome_inspect_styles - Inspect CSS cascade for an element
|
|
3
|
+
*
|
|
4
|
+
* Gets matched CSS rules through CDP's CSS.getMatchedStylesForNode API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ToolDefinition } from '../core/types';
|
|
8
|
+
import { Type } from '@sinclair/typebox';
|
|
9
|
+
|
|
10
|
+
export const inspectStylesTool: ToolDefinition<{
|
|
11
|
+
selector: string;
|
|
12
|
+
includeChildren?: boolean;
|
|
13
|
+
}> = {
|
|
14
|
+
name: 'chrome_inspect_styles',
|
|
15
|
+
label: 'Chrome Inspect Styles',
|
|
16
|
+
description: '查看元素的完整 CSS 样式层叠链:包括 inline style、class 样式、user-agent 样式,以及每条规则的来源文件和优先级。',
|
|
17
|
+
promptSnippet: '查看元素的 CSS 样式层叠链',
|
|
18
|
+
promptGuidelines: [
|
|
19
|
+
'【调试布局问题的核心工具】当元素显示异常(溢出、滚动条不出现、尺寸不对、布局错乱)时,立即用 chrome_inspect_styles 检查该元素及其父容器的样式层叠链,确认 inline style 是否覆盖了 CSS 规则、overflow 是否被意外设为 hidden 等。',
|
|
20
|
+
'调试滚动条问题时:依次检查 滚动容器 → 父容器 → 祖先容器的 overflow、display、height、flex 属性,定位哪一层断了高度约束链。',
|
|
21
|
+
'返回结果会按优先级排列(inline > CSS class > user-agent),注意 inline style 会覆盖 CSS 文件中的规则。',
|
|
22
|
+
'指定 CSS selector 来定位元素,例如 "#dashboardContainer"、".command-list"、"div > .card"。'
|
|
23
|
+
],
|
|
24
|
+
parameters: Type.Object({
|
|
25
|
+
selector: Type.String({ description: 'CSS selector of the element to inspect' }),
|
|
26
|
+
includeChildren: Type.Optional(Type.Boolean({ description: 'Include direct children list', default: false }))
|
|
27
|
+
}),
|
|
28
|
+
async execute(page, params) {
|
|
29
|
+
let cdpSession: any = null;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Create CDP session for this page
|
|
33
|
+
cdpSession = await page.createCDPSession();
|
|
34
|
+
|
|
35
|
+
// Enable DOM and CSS agents
|
|
36
|
+
await cdpSession.send('DOM.enable');
|
|
37
|
+
await cdpSession.send('CSS.enable');
|
|
38
|
+
|
|
39
|
+
// Get document and find node
|
|
40
|
+
const { root } = await cdpSession.send('DOM.getDocument', { depth: 0 });
|
|
41
|
+
const { nodeId } = await cdpSession.send('DOM.querySelector', {
|
|
42
|
+
selector: params.selector,
|
|
43
|
+
nodeId: root.nodeId
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!nodeId) {
|
|
47
|
+
throw new Error(`未找到匹配 "${params.selector}" 的元素`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Get element info
|
|
51
|
+
const { node: nodeInfo } = await cdpSession.send('DOM.describeNode', { nodeId });
|
|
52
|
+
|
|
53
|
+
// Get bounding rect
|
|
54
|
+
const boundingRect = await page.evaluate((sel: string) => {
|
|
55
|
+
const el = document.querySelector(sel);
|
|
56
|
+
if (!el) return null;
|
|
57
|
+
const r = el.getBoundingClientRect();
|
|
58
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
59
|
+
}, params.selector);
|
|
60
|
+
|
|
61
|
+
// Get text content (truncated)
|
|
62
|
+
const textContent = await page.evaluate((sel: string) => {
|
|
63
|
+
const el = document.querySelector(sel);
|
|
64
|
+
return (el?.textContent || '').trim().slice(0, 200);
|
|
65
|
+
}, params.selector);
|
|
66
|
+
|
|
67
|
+
// Build element info
|
|
68
|
+
const elementInfo = {
|
|
69
|
+
tagName: nodeInfo.localName?.toLowerCase() || 'unknown',
|
|
70
|
+
text: textContent,
|
|
71
|
+
classList: nodeInfo.attributes
|
|
72
|
+
? extractClasses(nodeInfo.attributes)
|
|
73
|
+
: [],
|
|
74
|
+
attributes: nodeInfo.attributes || [],
|
|
75
|
+
boundingRect
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Get matched styles
|
|
79
|
+
const matchedStyles = await cdpSession.send('CSS.getMatchedStylesForNode', { nodeId });
|
|
80
|
+
|
|
81
|
+
// Process CSS rules
|
|
82
|
+
const cssRules: any[] = [];
|
|
83
|
+
|
|
84
|
+
// Inline style
|
|
85
|
+
if (matchedStyles.inlineStyle) {
|
|
86
|
+
const inlineProps = processStyleProperties(matchedStyles.inlineStyle.cssProperties, matchedStyles.inlineStyle.shorthandEntries);
|
|
87
|
+
if (Object.keys(inlineProps).length > 0) {
|
|
88
|
+
cssRules.push({
|
|
89
|
+
type: 'inline',
|
|
90
|
+
selector: '<inline style>',
|
|
91
|
+
source: 'inline style',
|
|
92
|
+
properties: inlineProps
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Regular matched rules
|
|
98
|
+
if (matchedStyles.matchedCSSRules) {
|
|
99
|
+
for (const rule of matchedStyles.matchedCSSRules) {
|
|
100
|
+
const props = processStyleProperties(rule.rule.style.cssProperties, rule.rule.style.shorthandEntries);
|
|
101
|
+
|
|
102
|
+
// Build source string
|
|
103
|
+
const source = rule.rule.origin === 'user-agent'
|
|
104
|
+
? 'user-agent'
|
|
105
|
+
: rule.rule.selectorList?.selectors?.map((s: any) => s.value).join(', ') || 'unknown';
|
|
106
|
+
|
|
107
|
+
const sourceLocation = rule.rule.sourceURL
|
|
108
|
+
? `${rule.rule.sourceURL}:${rule.rule.sourceLine || '?'}`
|
|
109
|
+
: 'inline';
|
|
110
|
+
|
|
111
|
+
cssRules.push({
|
|
112
|
+
type: rule.rule.origin === 'user-agent' ? 'user-agent' : 'regular',
|
|
113
|
+
selector: source,
|
|
114
|
+
source: sourceLocation,
|
|
115
|
+
properties: props
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Build summary
|
|
121
|
+
const summary = `元素 <${elementInfo.tagName}> 的 CSS 层叠链:\n` +
|
|
122
|
+
cssRules.map((rule, i) =>
|
|
123
|
+
` ${i + 1}. [${rule.type}] ${rule.selector}\n 来源: ${rule.source}\n` +
|
|
124
|
+
(rule.properties.length > 0
|
|
125
|
+
? ` 属性: ${rule.properties.slice(0, 5).map((p: any) => `${p.name}: ${p.value}${p.important ? ' !important' : ''}`).join(', ')}${rule.properties.length > 5 ? '...' : ''}\n`
|
|
126
|
+
: '')
|
|
127
|
+
).join('');
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: 'text', text: summary }],
|
|
131
|
+
details: { element: elementInfo, cssRules }
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
} finally {
|
|
135
|
+
// Clean up CDP session
|
|
136
|
+
if (cdpSession) {
|
|
137
|
+
try {
|
|
138
|
+
await cdpSession.detach();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error('[pi-to-chrome] inspect-styles: cdpSession.detach() 失败', error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function extractClasses(attributes: string[]): string[] {
|
|
148
|
+
const classes: string[] = [];
|
|
149
|
+
for (let i = 0; i < attributes.length - 1; i += 2) {
|
|
150
|
+
if (attributes[i] === 'class') {
|
|
151
|
+
return attributes[i + 1].split(/\s+/).filter(c => c).slice(0, 10);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return classes;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function processStyleProperties(
|
|
158
|
+
cssProperties: any[],
|
|
159
|
+
shorthandEntries: any[]
|
|
160
|
+
): any[] {
|
|
161
|
+
if (!cssProperties) return [];
|
|
162
|
+
|
|
163
|
+
const result: any[] = [];
|
|
164
|
+
const seenShorthands = new Set<string>();
|
|
165
|
+
|
|
166
|
+
// Track which shorthands are explicitly set
|
|
167
|
+
for (const entry of shorthandEntries || []) {
|
|
168
|
+
if (entry.value > 0) {
|
|
169
|
+
seenShorthands.add(cssProperties[entry.value]?.name || '');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const prop of cssProperties) {
|
|
174
|
+
// Skip empty values
|
|
175
|
+
if (!prop.value || prop.value === '') continue;
|
|
176
|
+
|
|
177
|
+
// Skip inherit/initial/unset unless it's the only value (shorthand)
|
|
178
|
+
const isInheritOrInitial = ['inherit', 'initial', 'unset', 'revert'].includes(prop.value);
|
|
179
|
+
|
|
180
|
+
// Keep the property if:
|
|
181
|
+
// 1. It's a shorthand property (entry in shorthandEntries)
|
|
182
|
+
// 2. It's not a longhand of an explicitly set shorthand
|
|
183
|
+
// 3. It's inherit/initial/unset with no shorthand entry
|
|
184
|
+
const isShorthand = shorthandEntries?.some((e: any) =>
|
|
185
|
+
e.value === cssProperties.indexOf(prop) && e.value >= 0
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (isShorthand) {
|
|
189
|
+
result.push({
|
|
190
|
+
name: prop.name,
|
|
191
|
+
value: prop.value,
|
|
192
|
+
important: prop.important || false
|
|
193
|
+
});
|
|
194
|
+
} else if (!isInheritOrInitial) {
|
|
195
|
+
result.push({
|
|
196
|
+
name: prop.name,
|
|
197
|
+
value: prop.value,
|
|
198
|
+
important: prop.important || false
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return result;
|
|
204
|
+
}
|