yunti-browser-runtime 0.2.0 → 0.2.1
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 +20 -9
- package/docs/AGENT_WORKFLOW_CONTRACT.md +11 -0
- package/docs/EXECUTION_PLAN.md +54 -20
- package/docs/EXTENSION_DISTRIBUTION.md +1 -0
- package/docs/EXTENSION_PERMISSION_STRATEGY.md +9 -6
- package/docs/EXTENSION_STORE_COPY.md +4 -1
- package/docs/INSTALL.md +11 -6
- package/docs/NEXT_MAJOR_PLAN.md +5 -5
- package/docs/PROJECT_STATUS.md +46 -22
- package/docs/RELEASE_0_2_0_CHECKLIST.md +12 -5
- package/docs/TOOL_GUIDE.md +8 -0
- package/extension/background.js +20 -0
- package/extension/cdp.js +4 -0
- package/extension/content.js +100 -18
- package/extension/manifest.json +2 -1
- package/extension/popup.js +2 -2
- package/extension/session-manager.js +104 -4
- package/extension/tool-handlers.js +196 -288
- package/mcp/bridge-hub.js +4 -4
- package/mcp/tools.js +4 -0
- package/package.json +1 -1
- package/scripts/doctor.js +2 -1
- package/skills/yunti-browser-runtime/SKILL.md +3 -1
package/extension/content.js
CHANGED
|
@@ -338,7 +338,7 @@ function isExtensionContextInvalidated(error) {
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
function contextInvalidatedResponse() {
|
|
341
|
-
return { ok: false, error: "
|
|
341
|
+
return { ok: false, error: "插件已重新加载,正在等待扩展自动恢复连接;必要时再刷新页面。" }
|
|
342
342
|
}
|
|
343
343
|
|
|
344
344
|
function markExtensionContextInvalidated() {
|
|
@@ -382,6 +382,8 @@ async function executeTool(tool, args) {
|
|
|
382
382
|
return clickElement(args)
|
|
383
383
|
case "yunti_click_at":
|
|
384
384
|
return clickAt(args)
|
|
385
|
+
case "yunti_hover":
|
|
386
|
+
return hoverElement(args)
|
|
385
387
|
case "yunti_fill":
|
|
386
388
|
return fillElement(args)
|
|
387
389
|
case "yunti_type_text":
|
|
@@ -548,10 +550,13 @@ function rollbackPreviewPatch(patchId) {
|
|
|
548
550
|
}
|
|
549
551
|
|
|
550
552
|
function clickElement(args) {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
553
|
+
if (args.selector) {
|
|
554
|
+
const element = mustFind(args.selector)
|
|
555
|
+
element.scrollIntoView({ block: "center", inline: "center" })
|
|
556
|
+
element.click()
|
|
557
|
+
return { clicked: true, element: describeElement(element) }
|
|
558
|
+
}
|
|
559
|
+
return clickAt(args)
|
|
555
560
|
}
|
|
556
561
|
|
|
557
562
|
function clickAt(args) {
|
|
@@ -569,12 +574,36 @@ function clickAt(args) {
|
|
|
569
574
|
}
|
|
570
575
|
}
|
|
571
576
|
|
|
577
|
+
function hoverElement(args) {
|
|
578
|
+
const element = resolveTargetElement(args)
|
|
579
|
+
if (!element) throw new Error("yunti_hover requires selector or x/y coordinates")
|
|
580
|
+
element.scrollIntoView({ block: "center", inline: "center" })
|
|
581
|
+
const rect = element.getBoundingClientRect()
|
|
582
|
+
const x = Number.isFinite(Number(args.x)) ? Number(args.x) : rect.left + rect.width / 2
|
|
583
|
+
const y = Number.isFinite(Number(args.y)) ? Number(args.y) : rect.top + rect.height / 2
|
|
584
|
+
focusElement(element)
|
|
585
|
+
dispatchHoverSequence(element, x, y)
|
|
586
|
+
return {
|
|
587
|
+
hovered: true,
|
|
588
|
+
x: Math.round(x),
|
|
589
|
+
y: Math.round(y),
|
|
590
|
+
element: describeElement(element),
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
572
594
|
function fillElement(args) {
|
|
573
|
-
const element =
|
|
595
|
+
const element = resolveTargetElement(args)
|
|
596
|
+
if (!element) throw new Error("yunti_fill requires selector or x/y coordinates")
|
|
574
597
|
const value = String(args.value ?? "")
|
|
575
598
|
element.scrollIntoView({ block: "center", inline: "center" })
|
|
576
599
|
setEditableText(element, value, { replace: true })
|
|
577
|
-
return {
|
|
600
|
+
return {
|
|
601
|
+
filled: true,
|
|
602
|
+
element: describeElement(element),
|
|
603
|
+
valueLength: value.length,
|
|
604
|
+
valueApplied: getEditableText(element) === value,
|
|
605
|
+
method: element.isContentEditable ? "contenteditable" : element instanceof HTMLSelectElement ? "select" : "dom",
|
|
606
|
+
}
|
|
578
607
|
}
|
|
579
608
|
|
|
580
609
|
function typeText(args) {
|
|
@@ -612,32 +641,55 @@ function pressKey(args) {
|
|
|
612
641
|
}
|
|
613
642
|
|
|
614
643
|
function selectElement(args) {
|
|
615
|
-
const element =
|
|
644
|
+
const element = resolveTargetElement(args)
|
|
645
|
+
if (!element) throw new Error("yunti_select requires selector or x/y coordinates")
|
|
616
646
|
if (!(element instanceof HTMLSelectElement)) throw new Error("Target is not a select element")
|
|
617
647
|
const value = String(args.value ?? "")
|
|
648
|
+
const text = String(args.text ?? "")
|
|
649
|
+
const matchMode = text ? "text" : "value"
|
|
650
|
+
const targetOption = text || value
|
|
618
651
|
const options = Array.from(element.options)
|
|
619
|
-
const option = options.find((item) => item.value ===
|
|
652
|
+
const option = options.find((item) => matchMode === "text" ? item.text.trim() === targetOption : item.value === targetOption)
|
|
653
|
+
if (!option) {
|
|
654
|
+
return {
|
|
655
|
+
selected: false,
|
|
656
|
+
element: describeElement(element),
|
|
657
|
+
value: element.value,
|
|
658
|
+
code: "OPTION_NOT_FOUND",
|
|
659
|
+
error: matchMode === "text" ? "Option text not found" : "Option value not found",
|
|
660
|
+
matchMode,
|
|
661
|
+
targetOption,
|
|
662
|
+
availableValues: options.map((item) => item.value).slice(0, 50),
|
|
663
|
+
availableTexts: options.map((item) => item.text.trim()).slice(0, 50),
|
|
664
|
+
options: summarizeSelectOptions(options),
|
|
665
|
+
}
|
|
666
|
+
}
|
|
620
667
|
if (option?.disabled) {
|
|
621
668
|
return {
|
|
622
669
|
selected: false,
|
|
623
670
|
element: describeElement(element),
|
|
624
671
|
value: element.value,
|
|
625
672
|
code: "OPTION_DISABLED",
|
|
626
|
-
error: "Option value is disabled",
|
|
673
|
+
error: matchMode === "text" ? "Option text is disabled" : "Option value is disabled",
|
|
674
|
+
matchMode,
|
|
675
|
+
targetOption,
|
|
627
676
|
disabledValue: option.value,
|
|
628
677
|
disabledText: option.text.trim(),
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
disabled: Boolean(item.disabled),
|
|
633
|
-
selected: Boolean(item.selected),
|
|
634
|
-
})),
|
|
678
|
+
availableValues: options.map((item) => item.value).slice(0, 50),
|
|
679
|
+
availableTexts: options.map((item) => item.text.trim()).slice(0, 50),
|
|
680
|
+
options: summarizeSelectOptions(options),
|
|
635
681
|
}
|
|
636
682
|
}
|
|
637
|
-
element.value = value
|
|
683
|
+
element.value = option.value
|
|
638
684
|
element.dispatchEvent(new Event("input", { bubbles: true }))
|
|
639
685
|
element.dispatchEvent(new Event("change", { bubbles: true }))
|
|
640
|
-
return {
|
|
686
|
+
return {
|
|
687
|
+
selected: true,
|
|
688
|
+
element: describeElement(element),
|
|
689
|
+
value: element.value,
|
|
690
|
+
text: option.text.trim(),
|
|
691
|
+
selectedIndex: element.selectedIndex,
|
|
692
|
+
}
|
|
641
693
|
}
|
|
642
694
|
|
|
643
695
|
function scrollPage(args) {
|
|
@@ -693,6 +745,15 @@ function resolveTargetElement(args = {}) {
|
|
|
693
745
|
return null
|
|
694
746
|
}
|
|
695
747
|
|
|
748
|
+
function summarizeSelectOptions(options) {
|
|
749
|
+
return options.slice(0, 50).map((item) => ({
|
|
750
|
+
value: item.value,
|
|
751
|
+
text: item.text.trim(),
|
|
752
|
+
disabled: Boolean(item.disabled),
|
|
753
|
+
selected: Boolean(item.selected),
|
|
754
|
+
}))
|
|
755
|
+
}
|
|
756
|
+
|
|
696
757
|
function focusElement(element) {
|
|
697
758
|
if (element && typeof element.focus === "function") {
|
|
698
759
|
element.focus({ preventScroll: true })
|
|
@@ -720,6 +781,27 @@ function dispatchPointerMouseSequence(element, x, y) {
|
|
|
720
781
|
}
|
|
721
782
|
}
|
|
722
783
|
|
|
784
|
+
function dispatchHoverSequence(element, x, y) {
|
|
785
|
+
const common = {
|
|
786
|
+
bubbles: true,
|
|
787
|
+
cancelable: true,
|
|
788
|
+
composed: true,
|
|
789
|
+
clientX: x,
|
|
790
|
+
clientY: y,
|
|
791
|
+
view: window,
|
|
792
|
+
}
|
|
793
|
+
for (const type of ["pointerover", "pointerenter", "pointermove"]) {
|
|
794
|
+
try {
|
|
795
|
+
element.dispatchEvent(new PointerEvent(type, { ...common, pointerType: "mouse", isPrimary: true }))
|
|
796
|
+
} catch {
|
|
797
|
+
break
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
for (const type of ["mouseover", "mouseenter", "mousemove"]) {
|
|
801
|
+
element.dispatchEvent(new MouseEvent(type, common))
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
723
805
|
function setEditableText(element, text, options = {}) {
|
|
724
806
|
assertEditableTarget(element)
|
|
725
807
|
if (element.isContentEditable) {
|
package/extension/manifest.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Yunti Browser Runtime",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"description": "Local browser runtime for AI agents through MCP.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"activeTab",
|
|
8
8
|
"debugger",
|
|
9
|
+
"scripting",
|
|
9
10
|
"storage",
|
|
10
11
|
"tabs",
|
|
11
12
|
"webRequest"
|
package/extension/popup.js
CHANGED
|
@@ -33,7 +33,7 @@ async function save() {
|
|
|
33
33
|
return
|
|
34
34
|
}
|
|
35
35
|
renderSettings(state.settings || {})
|
|
36
|
-
setStatus("
|
|
36
|
+
setStatus("设置已保存。扩展会自动尝试重新注册可访问页面。")
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function renderSettings(settings) {
|
|
@@ -56,7 +56,7 @@ function statusText(state) {
|
|
|
56
56
|
if (!state.bridge?.ok) {
|
|
57
57
|
return "Bridge 未连接。请确认 Agent MCP 已启动,或运行 yunti-browser-runtime bridge。"
|
|
58
58
|
}
|
|
59
|
-
return "
|
|
59
|
+
return "默认无需设置。打开任意 http/https 页面后会自动连接。"
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function setStatus(text) {
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
export function createSessionManager() {
|
|
13
13
|
const sessionsByTab = new Map()
|
|
14
14
|
const pollers = new Map()
|
|
15
|
+
const pendingTabRecovery = new Map()
|
|
16
|
+
const lastInjectionAtByTab = new Map()
|
|
15
17
|
let cachedPlatformMatches = null
|
|
16
18
|
let toolRequestHandler = null
|
|
17
19
|
|
|
@@ -38,6 +40,104 @@ export function createSessionManager() {
|
|
|
38
40
|
}).catch(() => {})
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
async function ensureAllTabsRegistered(options = {}) {
|
|
44
|
+
const settings = await getSettings()
|
|
45
|
+
cachedPlatformMatches = settings.platformMatches
|
|
46
|
+
const tabs = await chrome.tabs.query({})
|
|
47
|
+
const results = await Promise.allSettled(
|
|
48
|
+
tabs.map((tab) => ensureTabRegistered(tab.id, { ...options, tab, settings }))
|
|
49
|
+
)
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
reason: options.reason || "manual",
|
|
53
|
+
checked: tabs.length,
|
|
54
|
+
recovered: results.filter((result) => result.value?.registered || result.value?.injected)
|
|
55
|
+
.length,
|
|
56
|
+
skipped: results.filter((result) => result.value?.skipped).length,
|
|
57
|
+
failed: results.filter((result) => result.status === "rejected" || result.value?.ok === false)
|
|
58
|
+
.length,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function ensureTabRegistered(tabId, options = {}) {
|
|
63
|
+
if (!Number.isFinite(Number(tabId))) {
|
|
64
|
+
return { ok: false, skipped: true, reason: "missing_tab_id" }
|
|
65
|
+
}
|
|
66
|
+
const key = Number(tabId)
|
|
67
|
+
if (pendingTabRecovery.has(key)) return pendingTabRecovery.get(key)
|
|
68
|
+
const promise = recoverTabRegistration(key, options).finally(() => {
|
|
69
|
+
pendingTabRecovery.delete(key)
|
|
70
|
+
})
|
|
71
|
+
pendingTabRecovery.set(key, promise)
|
|
72
|
+
return promise
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function recoverTabRegistration(tabId, options = {}) {
|
|
76
|
+
const settings = options.settings || (await getSettings())
|
|
77
|
+
cachedPlatformMatches = settings.platformMatches
|
|
78
|
+
const tab = options.tab || (await chrome.tabs.get(tabId).catch(() => null))
|
|
79
|
+
if (!isInjectableTab(tab, settings)) {
|
|
80
|
+
return { ok: true, skipped: true, reason: "unsupported_tab" }
|
|
81
|
+
}
|
|
82
|
+
if (sessionsByTab.has(tabId)) {
|
|
83
|
+
await refreshTabRegistration(tabId).catch(() => null)
|
|
84
|
+
return { ok: true, registered: true, reason: "already_registered" }
|
|
85
|
+
}
|
|
86
|
+
const ping = await refreshTabRegistration(tabId).catch((error) => ({
|
|
87
|
+
ok: false,
|
|
88
|
+
error: error?.message || String(error),
|
|
89
|
+
}))
|
|
90
|
+
if (ping?.ok) {
|
|
91
|
+
return { ok: true, registered: true, reason: "content_script_present" }
|
|
92
|
+
}
|
|
93
|
+
const lastInjectionAt = lastInjectionAtByTab.get(tabId) || 0
|
|
94
|
+
if (Date.now() - lastInjectionAt < 5000) {
|
|
95
|
+
return { ok: true, skipped: true, reason: "recently_injected" }
|
|
96
|
+
}
|
|
97
|
+
const injected = await injectContentScripts(tabId).catch((error) => ({
|
|
98
|
+
ok: false,
|
|
99
|
+
error: error?.message || String(error),
|
|
100
|
+
}))
|
|
101
|
+
if (!injected?.ok) {
|
|
102
|
+
return { ok: false, reason: "inject_failed", error: injected?.error || "inject failed" }
|
|
103
|
+
}
|
|
104
|
+
lastInjectionAtByTab.set(tabId, Date.now())
|
|
105
|
+
await delay(50)
|
|
106
|
+
await refreshTabRegistration(tabId).catch(() => null)
|
|
107
|
+
return { ok: true, injected: true, reason: options.reason || "auto_recovery" }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isInjectableTab(tab, settings) {
|
|
111
|
+
if (!tab?.id) return false
|
|
112
|
+
if (tab.discarded) return false
|
|
113
|
+
return isPlatformUrl(tab.url, settings)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function refreshTabRegistration(tabId) {
|
|
117
|
+
return chrome.tabs.sendMessage(tabId, { type: "yunti_refresh_registration" })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function injectContentScripts(tabId) {
|
|
121
|
+
if (!chrome.scripting?.executeScript) {
|
|
122
|
+
return { ok: false, error: "chrome.scripting permission is unavailable" }
|
|
123
|
+
}
|
|
124
|
+
if (chrome.scripting.insertCSS) {
|
|
125
|
+
await chrome.scripting.insertCSS({
|
|
126
|
+
target: { tabId },
|
|
127
|
+
files: ["content.css"],
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
await chrome.scripting.executeScript({
|
|
131
|
+
target: { tabId },
|
|
132
|
+
files: ["dom-observer.js"],
|
|
133
|
+
})
|
|
134
|
+
await chrome.scripting.executeScript({
|
|
135
|
+
target: { tabId },
|
|
136
|
+
files: ["content.js"],
|
|
137
|
+
})
|
|
138
|
+
return { ok: true }
|
|
139
|
+
}
|
|
140
|
+
|
|
41
141
|
async function handleMessage(message, sender) {
|
|
42
142
|
switch (message?.type) {
|
|
43
143
|
case "yunti_content_ready":
|
|
@@ -244,9 +344,7 @@ export function createSessionManager() {
|
|
|
244
344
|
async function refreshActiveTab() {
|
|
245
345
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
|
|
246
346
|
if (!tab?.id) return { ok: false, error: "no active tab" }
|
|
247
|
-
await
|
|
248
|
-
.sendMessage(tab.id, { type: "yunti_refresh_registration" })
|
|
249
|
-
.catch(() => null)
|
|
347
|
+
await ensureTabRegistered(tab.id, { tab, reason: "popup_refresh" }).catch(() => null)
|
|
250
348
|
return getPanelState()
|
|
251
349
|
}
|
|
252
350
|
|
|
@@ -254,6 +352,8 @@ export function createSessionManager() {
|
|
|
254
352
|
sessionsByTab,
|
|
255
353
|
pollers,
|
|
256
354
|
activateTab,
|
|
355
|
+
ensureAllTabsRegistered,
|
|
356
|
+
ensureTabRegistered,
|
|
257
357
|
forgetTab,
|
|
258
358
|
forwardConsoleEvent,
|
|
259
359
|
getPlatformMatches,
|
|
@@ -279,7 +379,7 @@ function normalizePageAuth(auth, source = "unknown") {
|
|
|
279
379
|
return {
|
|
280
380
|
state: "missing_from_content_script",
|
|
281
381
|
loggedIn: false,
|
|
282
|
-
reason: "Content script did not report page state;
|
|
382
|
+
reason: "Content script did not report page state; Yunti will try automatic registration recovery before asking for a page refresh",
|
|
283
383
|
checkedAt: new Date().toISOString(),
|
|
284
384
|
checkedBy: source,
|
|
285
385
|
}
|