openclacky 0.9.7 → 0.9.9
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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +16 -1
- data/CHANGELOG.md +51 -0
- data/docs/browser-cdp-native-design.md +195 -0
- data/docs/session-management-redesign.md +202 -0
- data/docs/system-skill-authoring-guide.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +2 -2
- data/lib/clacky/agent/session_serializer.rb +36 -3
- data/lib/clacky/agent/skill_manager.rb +68 -5
- data/lib/clacky/agent/system_prompt_builder.rb +30 -13
- data/lib/clacky/agent/tool_executor.rb +8 -12
- data/lib/clacky/agent.rb +172 -30
- data/lib/clacky/brand_config.rb +90 -0
- data/lib/clacky/cli.rb +37 -8
- data/lib/clacky/default_parsers/doc_parser.rb +69 -0
- data/lib/clacky/default_parsers/docx_parser.rb +172 -0
- data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
- data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
- data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
- data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
- data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
- data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
- data/lib/clacky/json_ui_controller.rb +5 -0
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/plain_ui_controller.rb +5 -0
- data/lib/clacky/server/browser_manager.rb +308 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
- data/lib/clacky/server/channel/channel_config.rb +6 -0
- data/lib/clacky/server/channel/channel_manager.rb +50 -15
- data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
- data/lib/clacky/server/channel.rb +1 -0
- data/lib/clacky/server/http_server.rb +210 -45
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/session_registry.rb +131 -40
- data/lib/clacky/server/web_ui_controller.rb +21 -2
- data/lib/clacky/session_manager.rb +43 -73
- data/lib/clacky/skill.rb +64 -42
- data/lib/clacky/tools/browser.rb +728 -183
- data/lib/clacky/tools/invoke_skill.rb +18 -11
- data/lib/clacky/tools/shell.rb +16 -7
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/README.md +1 -1
- data/lib/clacky/ui2/components/input_area.rb +5 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
- data/lib/clacky/ui2/ui_controller.rb +16 -2
- data/lib/clacky/ui_interface.rb +7 -0
- data/lib/clacky/utils/file_processor.rb +116 -139
- data/lib/clacky/utils/model_pricing.rb +5 -4
- data/lib/clacky/utils/parser_manager.rb +93 -0
- data/lib/clacky/utils/workspace_rules.rb +46 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +696 -62
- data/lib/clacky/web/app.js +181 -51
- data/lib/clacky/web/brand.js +14 -0
- data/lib/clacky/web/channels.js +10 -1
- data/lib/clacky/web/i18n.js +49 -7
- data/lib/clacky/web/index.html +67 -10
- data/lib/clacky/web/onboard.js +2 -1
- data/lib/clacky/web/sessions.js +255 -69
- data/lib/clacky/web/settings.js +113 -1
- data/lib/clacky/web/skills.js +16 -3
- data/lib/clacky/web/tasks.js +11 -6
- data/lib/clacky/web/weixin-qr.html +104 -0
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +41 -11
- metadata +48 -7
- data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
- data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
- data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
- data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
- data/scripts/install_agent_browser.sh +0 -67
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -1,228 +1,785 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "tmpdir"
|
|
3
7
|
require "shellwords"
|
|
4
8
|
require "yaml"
|
|
5
|
-
require "tmpdir"
|
|
6
9
|
require_relative "base"
|
|
7
|
-
require_relative "shell"
|
|
8
10
|
|
|
9
11
|
module Clacky
|
|
10
12
|
module Tools
|
|
13
|
+
# Browser tool — controls the user's real Chromium-based browser (Chrome 146+)
|
|
14
|
+
# via the Chrome DevTools MCP server (chrome-devtools-mcp).
|
|
15
|
+
#
|
|
16
|
+
# Architecture: profile="user" uses the existing-session driver (Chrome MCP).
|
|
17
|
+
# chrome-devtools-mcp --autoConnect --experimentalStructuredContent
|
|
18
|
+
# --experimental-page-id-routing [--userDataDir <path>]
|
|
19
|
+
#
|
|
20
|
+
# Communication: MCP stdio JSON-RPC 2.0 over a *persistent* (daemon) process.
|
|
21
|
+
# The MCP server process is started once, kept alive across all tool calls,
|
|
22
|
+
# and only restarted when the process dies unexpectedly. This means Chrome
|
|
23
|
+
# shows the "Allow remote debugging" dialog exactly once per daemon lifetime.
|
|
24
|
+
#
|
|
25
|
+
# No agent-browser, no DevToolsActivePort, no CDP port management.
|
|
11
26
|
class Browser < Base
|
|
12
27
|
self.tool_name = "browser"
|
|
13
28
|
self.tool_description = <<~DESC
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
Control the browser for automation tasks (login, form submission, UI interaction, scraping).
|
|
30
|
+
For simple page fetch or search, prefer web_fetch or web_search instead.
|
|
31
|
+
|
|
32
|
+
Uses your real Chrome browser (profile="user") with existing logins & cookies. Requires Chrome 146+.
|
|
33
|
+
|
|
34
|
+
ACTIONS OVERVIEW:
|
|
35
|
+
- snapshot → get accessibility tree with element refs. ALWAYS run before interacting.
|
|
36
|
+
- act → interact with page: click, type, fill, press, hover, scroll, drag, select, wait, evaluate
|
|
37
|
+
- open → open URL in a new tab
|
|
38
|
+
- navigate → navigate current tab to URL
|
|
39
|
+
- tabs → list open tabs
|
|
40
|
+
- focus → switch to a tab by targetId
|
|
41
|
+
- close → close current tab
|
|
42
|
+
- screenshot → EXPENSIVE. Only use when user explicitly asks to "see" or "show" the page. NEVER call without ref= unless user asks for a visual. Use ref= to screenshot a single element (much cheaper).
|
|
43
|
+
- status → check if browser is running
|
|
44
|
+
|
|
45
|
+
SNAPSHOT WORKFLOW — always snapshot first:
|
|
46
|
+
- action="snapshot" → full accessibility tree
|
|
47
|
+
- action="snapshot", interactive=true → interactive elements only (recommended)
|
|
48
|
+
- action="snapshot", interactive=true, compact=true → compact interactive
|
|
49
|
+
|
|
50
|
+
SCREENSHOT RULES — read before calling screenshot:
|
|
51
|
+
1. DEFAULT: use snapshot to understand the page. snapshot is FREE; screenshot costs ~30K tokens.
|
|
52
|
+
2. WITH ref=: screenshot a single element (e.g. ref="e5") — costs ~1-2K tokens. OK to use.
|
|
53
|
+
3. WITHOUT ref= (full page): ONLY if user explicitly says "show me", "screenshot", "what does it look like". NEVER call proactively.
|
|
54
|
+
4. If you want to check state / find elements / verify result → use snapshot, NOT screenshot.
|
|
55
|
+
|
|
56
|
+
ACT KINDS: click, dblclick, type, fill, press, hover, drag, select, scroll, wait, evaluate, click_at
|
|
57
|
+
- click: ref="e1"
|
|
58
|
+
- click_at: x=100, y=200 → coordinate click, use when ref-based click fails (React/virtual lists)
|
|
59
|
+
- fill: ref="e1", text="value"
|
|
60
|
+
- press: key="Enter"
|
|
61
|
+
- scroll: direction="down", amount=300
|
|
62
|
+
- wait: ms=2000 OR selector=".spinner"
|
|
63
|
+
- evaluate: js="document.title"
|
|
64
|
+
|
|
65
|
+
TARGETING TABS — pass target_id from snapshot/tabs to subsequent acts.
|
|
32
66
|
DESC
|
|
33
67
|
self.tool_category = "web"
|
|
34
68
|
self.tool_parameters = {
|
|
35
69
|
type: "object",
|
|
36
70
|
properties: {
|
|
37
|
-
|
|
71
|
+
action: {
|
|
38
72
|
type: "string",
|
|
39
|
-
|
|
40
|
-
|
|
73
|
+
enum: %w[snapshot act open navigate tabs focus close screenshot status],
|
|
74
|
+
description: "Action to perform."
|
|
75
|
+
},
|
|
76
|
+
profile: {
|
|
77
|
+
type: "string",
|
|
78
|
+
enum: %w[user],
|
|
79
|
+
description: "Browser profile. Only 'user' is supported — uses your real Chrome browser with existing logins & cookies."
|
|
80
|
+
},
|
|
81
|
+
interactive: {
|
|
82
|
+
type: "boolean",
|
|
83
|
+
description: "snapshot: only include interactive elements."
|
|
84
|
+
},
|
|
85
|
+
compact: {
|
|
86
|
+
type: "boolean",
|
|
87
|
+
description: "snapshot: remove empty structural elements."
|
|
88
|
+
},
|
|
89
|
+
depth: {
|
|
90
|
+
type: "integer",
|
|
91
|
+
description: "snapshot: max tree depth."
|
|
92
|
+
},
|
|
93
|
+
selector: {
|
|
94
|
+
type: "string",
|
|
95
|
+
description: "snapshot scope / act CSS selector."
|
|
96
|
+
},
|
|
97
|
+
kind: {
|
|
98
|
+
type: "string",
|
|
99
|
+
enum: %w[click dblclick type fill press hover drag select scroll wait evaluate click_at],
|
|
100
|
+
description: "act: interaction kind."
|
|
101
|
+
},
|
|
102
|
+
ref: {
|
|
103
|
+
type: "string",
|
|
104
|
+
description: "act: element ref from snapshot (e.g. 'e1'). screenshot: capture only this element (much cheaper than full-page)."
|
|
105
|
+
},
|
|
106
|
+
text: { type: "string", description: "act type/fill: text to enter." },
|
|
107
|
+
key: { type: "string", description: "act press: key (e.g. 'Enter')." },
|
|
108
|
+
direction: {
|
|
109
|
+
type: "string",
|
|
110
|
+
enum: %w[up down left right],
|
|
111
|
+
description: "act scroll: direction."
|
|
112
|
+
},
|
|
113
|
+
amount: { type: "integer", description: "act scroll: pixels." },
|
|
114
|
+
ms: { type: "integer", description: "act wait: milliseconds." },
|
|
115
|
+
load_state: {
|
|
116
|
+
type: "string",
|
|
117
|
+
enum: %w[load domcontentloaded networkidle],
|
|
118
|
+
description: "act wait: page load state."
|
|
119
|
+
},
|
|
120
|
+
js: { type: "string", description: "act evaluate: JS expression." },
|
|
121
|
+
target_ref: { type: "string", description: "act drag: destination ref." },
|
|
122
|
+
values: {
|
|
123
|
+
type: "array",
|
|
124
|
+
items: { type: "string" },
|
|
125
|
+
description: "act select: option values."
|
|
126
|
+
},
|
|
127
|
+
double_click: { type: "boolean", description: "act click: double-click." },
|
|
128
|
+
x: { type: "number", description: "act click_at: x coordinate in pixels." },
|
|
129
|
+
y: { type: "number", description: "act click_at: y coordinate in pixels." },
|
|
130
|
+
url: { type: "string", description: "open/navigate: URL." },
|
|
131
|
+
target_id: { type: "string", description: "tab targetId from open/tabs." },
|
|
132
|
+
format: {
|
|
133
|
+
type: "string",
|
|
134
|
+
enum: %w[png jpeg],
|
|
135
|
+
description: "screenshot: format (default jpeg)."
|
|
136
|
+
},
|
|
137
|
+
quality: { type: "integer", description: "screenshot: JPEG quality 0-100." },
|
|
138
|
+
full_page: { type: "boolean", description: "screenshot: full scrollable page." }
|
|
41
139
|
},
|
|
42
|
-
required: ["
|
|
140
|
+
required: ["action"]
|
|
43
141
|
}
|
|
44
142
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
143
|
+
# Chrome MCP binary args (chrome-devtools-mcp is installed globally via npm install -g)
|
|
144
|
+
CHROME_MCP_BASE_ARGS = %w[
|
|
145
|
+
--autoConnect
|
|
146
|
+
--experimentalStructuredContent
|
|
147
|
+
--experimental-page-id-routing
|
|
148
|
+
--experimentalVision
|
|
149
|
+
].freeze
|
|
48
150
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
151
|
+
# Minimum Chrome major version for Chrome MCP support
|
|
152
|
+
MIN_CHROME_MAJOR = 146
|
|
153
|
+
|
|
154
|
+
# MCP handshake/call timeout (seconds)
|
|
155
|
+
MCP_HANDSHAKE_TIMEOUT = 5
|
|
156
|
+
MCP_CALL_TIMEOUT = 10
|
|
157
|
+
|
|
158
|
+
# Minimum Node.js major version required by chrome-devtools-mcp
|
|
159
|
+
MIN_NODE_MAJOR = 20
|
|
160
|
+
|
|
161
|
+
MAX_SNAPSHOT_CHARS = 4000
|
|
162
|
+
MAX_LLM_OUTPUT_CHARS = 6000
|
|
163
|
+
|
|
164
|
+
# MCP daemon is managed by Clacky::BrowserManager (see lib/clacky/server/browser_manager.rb).
|
|
165
|
+
# Browser tool delegates all mcp_call / lifecycle operations to it via Clacky.browser_manager.
|
|
166
|
+
|
|
167
|
+
def execute(action:, profile: nil, working_dir: nil, **opts)
|
|
168
|
+
bypass = action.to_s == "status" ||
|
|
169
|
+
(action.to_s == "act" && (opts[:kind] || opts["kind"]).to_s == "evaluate")
|
|
170
|
+
unless bypass
|
|
171
|
+
return browser_not_setup_error unless File.exist?(BROWSER_CONFIG_PATH)
|
|
172
|
+
return browser_disabled_error unless browser_enabled?
|
|
71
173
|
end
|
|
174
|
+
execute_user_browser(action, opts)
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
{ error: "Browser error: #{e.message}\n\n#{BROWSER_RECONNECT_HINT}" }
|
|
177
|
+
end
|
|
72
178
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
179
|
+
def format_call(args)
|
|
180
|
+
action = args[:action] || args["action"] || "browser"
|
|
181
|
+
profile = args[:profile] || args["profile"]
|
|
182
|
+
suffix = profile ? "(#{action}, profile=#{profile})" : "(#{action})"
|
|
183
|
+
"browser#{suffix}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def format_result(result)
|
|
187
|
+
return "[Error] #{result[:error].to_s[0..80]}" if result[:error]
|
|
188
|
+
return "[OK] #{result[:output].to_s.lines.size} lines" if result[:output]
|
|
189
|
+
"[OK] Done"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def format_result_for_llm(result)
|
|
193
|
+
return result if result[:error]
|
|
194
|
+
|
|
195
|
+
action = result[:action].to_s
|
|
196
|
+
|
|
197
|
+
# Screenshot with inline image data — return multipart content blocks so the
|
|
198
|
+
# LLM can actually see the image (both Anthropic and OpenAI vision formats).
|
|
199
|
+
if action == "screenshot" && result[:image_data]
|
|
200
|
+
mime_type = result[:mime_type] || "image/jpeg"
|
|
201
|
+
image_data = result[:image_data]
|
|
202
|
+
data_url = "data:#{mime_type};base64,#{image_data}"
|
|
203
|
+
# OpenAI vision format (also accepted by Anthropic via client conversion)
|
|
204
|
+
return [
|
|
205
|
+
{ type: "text", text: "Screenshot captured." },
|
|
206
|
+
{ type: "image_url", image_url: { url: data_url } }
|
|
207
|
+
]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
output = result[:output].to_s
|
|
211
|
+
output = compress_snapshot(output) if action == "snapshot"
|
|
212
|
+
max_chars = action == "snapshot" ? MAX_SNAPSHOT_CHARS : MAX_LLM_OUTPUT_CHARS
|
|
213
|
+
|
|
214
|
+
truncated = truncate_output(output, max_chars)
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
action: action,
|
|
218
|
+
success: result[:success],
|
|
219
|
+
stdout: truncated,
|
|
220
|
+
profile: result[:profile]
|
|
221
|
+
}.compact
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# -----------------------------------------------------------------------
|
|
227
|
+
# User browser (Chrome MCP / existing-session driver)
|
|
228
|
+
# -----------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
# Friendly setup guide returned when Chrome is not installed or not running.
|
|
231
|
+
# Shown to the user (and Agent) when Chrome remote debugging is not enabled.
|
|
232
|
+
# The strong wording ("STOP", "DO NOT") is intentional — it prevents the
|
|
233
|
+
# Agent from silently falling back to web_search or other workarounds.
|
|
234
|
+
BROWSER_CONFIG_PATH = File.expand_path("~/.clacky/browser.yml").freeze
|
|
235
|
+
|
|
236
|
+
# Shown when any browser action fails at runtime (Chrome closed, lost connection, etc.)
|
|
237
|
+
BROWSER_RECONNECT_HINT = <<~HINT.strip.freeze
|
|
238
|
+
Common causes for browser connection failure:
|
|
239
|
+
1. Chrome is not running — ask the user to open Chrome.
|
|
240
|
+
2. Remote Debugging is disabled — Chrome must be launched with --remote-debugging-port=9222.
|
|
241
|
+
3. The browser MCP daemon crashed or lost the connection — it may recover on the next action.
|
|
242
|
+
4. Chrome has been running for a long time and the CDP connection became unstable — restart Chrome to fix.
|
|
243
|
+
|
|
244
|
+
Inform the user of these possible causes and ask if they'd like to run a diagnosis.
|
|
245
|
+
If yes, invoke the browser-setup skill with subcommand "doctor" to diagnose and fix.
|
|
246
|
+
HINT
|
|
247
|
+
|
|
248
|
+
# Returns true if ~/.clacky/browser.yml exists and enabled: true.
|
|
249
|
+
# Returns true if browser.yml exists and enabled: true.
|
|
250
|
+
private def browser_enabled?
|
|
251
|
+
config = YAML.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol])
|
|
252
|
+
config.is_a?(Hash) && config["enabled"] == true
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Error when browser.yml doesn't exist — never been set up.
|
|
256
|
+
private def browser_not_setup_error
|
|
257
|
+
{
|
|
258
|
+
error: <<~MSG
|
|
259
|
+
The browser tool is not configured. This tool call has been rejected to protect user experience.
|
|
260
|
+
|
|
261
|
+
Ask the user if they'd like to set up the browser, then invoke the browser-setup skill to guide them through the setup. Retry this tool call after setup is complete.
|
|
262
|
+
MSG
|
|
263
|
+
}
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Error when browser.yml exists but enabled: false — user explicitly disabled it.
|
|
267
|
+
private def browser_disabled_error
|
|
268
|
+
{
|
|
269
|
+
error: <<~MSG
|
|
270
|
+
The browser tool is disabled by the user. This tool call has been rejected.
|
|
271
|
+
|
|
272
|
+
Inform the user that they have disabled the browser tool. They can re-enable it from settings or by running "/browser-setup".
|
|
273
|
+
MSG
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private def execute_user_browser(action, opts)
|
|
278
|
+
if (err = node_error)
|
|
279
|
+
return err
|
|
78
280
|
end
|
|
281
|
+
|
|
282
|
+
case action.to_s
|
|
283
|
+
when "tabs"
|
|
284
|
+
result = mcp_call("list_pages")
|
|
285
|
+
pages = extract_pages(result)
|
|
286
|
+
{ action: "tabs", success: true, profile: "user", output: format_tabs(pages), tabs: pages }
|
|
287
|
+
when "snapshot"
|
|
288
|
+
do_user_snapshot(opts)
|
|
289
|
+
when "open"
|
|
290
|
+
url = require_url(opts)
|
|
291
|
+
return url if url.is_a?(Hash)
|
|
292
|
+
result = mcp_call("new_page", { url: url })
|
|
293
|
+
pages = extract_pages(result)
|
|
294
|
+
page = pages.last || {}
|
|
295
|
+
{ action: "open", success: true, profile: "user",
|
|
296
|
+
targetId: page[:id]&.to_s, url: url, output: "Opened: #{url}" }
|
|
297
|
+
when "navigate"
|
|
298
|
+
url = require_url(opts)
|
|
299
|
+
return url if url.is_a?(Hash)
|
|
300
|
+
target_id = resolve_target_id(opts)
|
|
301
|
+
return target_id if target_id.is_a?(Hash)
|
|
302
|
+
mcp_call("navigate_page", { pageId: target_id.to_i, type: "url", url: url })
|
|
303
|
+
{ action: "navigate", success: true, profile: "user",
|
|
304
|
+
targetId: target_id.to_s, url: url, output: "Navigated to: #{url}" }
|
|
305
|
+
when "focus"
|
|
306
|
+
target_id = resolve_target_id(opts)
|
|
307
|
+
return target_id if target_id.is_a?(Hash)
|
|
308
|
+
mcp_call("select_page", { pageId: target_id.to_i, bringToFront: true })
|
|
309
|
+
{ action: "focus", success: true, profile: "user", output: "Focused tab #{target_id}" }
|
|
310
|
+
when "close"
|
|
311
|
+
target_id = resolve_target_id(opts)
|
|
312
|
+
return target_id if target_id.is_a?(Hash)
|
|
313
|
+
mcp_call("close_page", { pageId: target_id.to_i })
|
|
314
|
+
{ action: "close", success: true, profile: "user", output: "Closed tab #{target_id}" }
|
|
315
|
+
when "act"
|
|
316
|
+
do_user_act(opts)
|
|
317
|
+
when "screenshot"
|
|
318
|
+
do_user_screenshot(opts)
|
|
319
|
+
when "status"
|
|
320
|
+
result = mcp_call("list_pages")
|
|
321
|
+
pages = extract_pages(result)
|
|
322
|
+
{ action: "status", success: true, profile: "user",
|
|
323
|
+
output: "Browser running. #{pages.size} tab(s) open.", tabs: pages }
|
|
324
|
+
else
|
|
325
|
+
{ error: "Action '#{action}' is not supported for profile=user." }
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
private def do_user_snapshot(opts)
|
|
330
|
+
target_id = resolve_target_id(opts)
|
|
331
|
+
return target_id if target_id.is_a?(Hash)
|
|
332
|
+
|
|
333
|
+
raw = mcp_call("take_snapshot", { pageId: target_id.to_i })
|
|
334
|
+
snapshot_node = extract_snapshot(raw)
|
|
335
|
+
|
|
336
|
+
interactive = opts[:interactive] || opts["interactive"]
|
|
337
|
+
compact_opt = opts[:compact] || opts["compact"]
|
|
338
|
+
max_depth = opts[:depth] || opts["depth"]
|
|
339
|
+
|
|
340
|
+
text = build_ai_snapshot(snapshot_node,
|
|
341
|
+
interactive: interactive,
|
|
342
|
+
compact: compact_opt,
|
|
343
|
+
max_depth: max_depth)
|
|
344
|
+
|
|
345
|
+
{ action: "snapshot", success: true, profile: "user",
|
|
346
|
+
targetId: target_id.to_s, output: text }
|
|
79
347
|
end
|
|
80
348
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
349
|
+
private def do_user_act(opts)
|
|
350
|
+
kind = (opts[:kind] || opts["kind"] || "click").to_s
|
|
351
|
+
target_id = resolve_target_id(opts)
|
|
352
|
+
return target_id if target_id.is_a?(Hash)
|
|
353
|
+
|
|
354
|
+
page_id = target_id.to_i
|
|
355
|
+
ref = opts[:ref] || opts["ref"]
|
|
356
|
+
|
|
357
|
+
case kind
|
|
358
|
+
when "click", "dblclick"
|
|
359
|
+
uid = require_ref(ref)
|
|
360
|
+
return uid if uid.is_a?(Hash)
|
|
361
|
+
args = { pageId: page_id, uid: uid }
|
|
362
|
+
args[:dblClick] = true if kind == "dblclick" || opts[:double_click] || opts["double_click"]
|
|
363
|
+
mcp_call("click", args)
|
|
364
|
+
when "fill"
|
|
365
|
+
uid = require_ref(ref)
|
|
366
|
+
return uid if uid.is_a?(Hash)
|
|
367
|
+
value = opts[:text] || opts["text"] || ""
|
|
368
|
+
mcp_call("fill", { pageId: page_id, uid: uid, value: value })
|
|
369
|
+
when "type"
|
|
370
|
+
uid = require_ref(ref)
|
|
371
|
+
return uid if uid.is_a?(Hash)
|
|
372
|
+
value = opts[:text] || opts["text"] || ""
|
|
373
|
+
mcp_call("fill", { pageId: page_id, uid: uid, value: value })
|
|
374
|
+
when "press"
|
|
375
|
+
key = opts[:key] || opts["key"] || "Enter"
|
|
376
|
+
mcp_call("press_key", { pageId: page_id, key: key })
|
|
377
|
+
when "hover"
|
|
378
|
+
uid = require_ref(ref)
|
|
379
|
+
return uid if uid.is_a?(Hash)
|
|
380
|
+
mcp_call("hover", { pageId: page_id, uid: uid })
|
|
381
|
+
when "drag"
|
|
382
|
+
uid = require_ref(ref)
|
|
383
|
+
return uid if uid.is_a?(Hash)
|
|
384
|
+
target_uid = opts[:target_ref] || opts["target_ref"] || ""
|
|
385
|
+
mcp_call("drag", { pageId: page_id, from_uid: uid, to_uid: target_uid })
|
|
386
|
+
when "select"
|
|
387
|
+
uid = require_ref(ref)
|
|
388
|
+
return uid if uid.is_a?(Hash)
|
|
389
|
+
values = Array(opts[:values] || opts["values"] || [])
|
|
390
|
+
mcp_call("fill", { pageId: page_id, uid: uid, value: values.first.to_s })
|
|
391
|
+
when "scroll"
|
|
392
|
+
direction = opts[:direction] || opts["direction"] || "down"
|
|
393
|
+
amount = opts[:amount] || opts["amount"] || 300
|
|
394
|
+
js = "window.scrollBy(#{direction == 'right' || direction == 'left' ?
|
|
395
|
+
(direction == 'left' ? -amount.to_i : amount.to_i) : 0
|
|
396
|
+
}, #{direction == 'up' ? -amount.to_i :
|
|
397
|
+
direction == 'down' ? amount.to_i : 0})"
|
|
398
|
+
mcp_call("evaluate_script", { pageId: page_id, function: "() => { #{js} }" })
|
|
399
|
+
when "wait"
|
|
400
|
+
ms = opts[:ms] || opts["ms"]
|
|
401
|
+
load_state = opts[:load_state] || opts["load_state"]
|
|
402
|
+
sel = opts[:selector] || opts["selector"]
|
|
403
|
+
if ms
|
|
404
|
+
sleep(ms.to_i / 1000.0)
|
|
405
|
+
{ action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
|
|
406
|
+
return { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
|
|
407
|
+
elsif sel
|
|
408
|
+
mcp_call("wait_for", { pageId: page_id, text: [sel] })
|
|
409
|
+
else
|
|
410
|
+
sleep(1)
|
|
411
|
+
end
|
|
412
|
+
when "evaluate"
|
|
413
|
+
js = opts[:js] || opts["js"] || ""
|
|
414
|
+
result = mcp_call("evaluate_script", {
|
|
415
|
+
pageId: page_id,
|
|
416
|
+
function: "() => { return (#{js}) }"
|
|
417
|
+
})
|
|
418
|
+
value = extract_message(result)
|
|
419
|
+
return { action: "act", success: true, profile: "user",
|
|
420
|
+
output: value.to_s }
|
|
421
|
+
when "click_at"
|
|
422
|
+
x = opts[:x] || opts["x"]
|
|
423
|
+
y = opts[:y] || opts["y"]
|
|
424
|
+
return { error: "click_at requires x and y coordinates" } unless x && y
|
|
425
|
+
|
|
426
|
+
click_args = { pageId: page_id, x: x.to_f, y: y.to_f }
|
|
427
|
+
click_args[:dblClick] = true if opts[:double_click] || opts["double_click"]
|
|
428
|
+
result = mcp_call("click_at", click_args)
|
|
429
|
+
return { action: "act", success: true, profile: "user",
|
|
430
|
+
output: extract_message(result).to_s }
|
|
431
|
+
else
|
|
432
|
+
return { error: "Unknown act kind: #{kind}" }
|
|
84
433
|
end
|
|
85
434
|
|
|
86
|
-
|
|
435
|
+
{ action: "act", success: true, profile: "user", output: "#{kind} completed." }
|
|
436
|
+
end
|
|
87
437
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
438
|
+
# Max width (px) for screenshots sent to the LLM.
|
|
439
|
+
# Retina/4K screens produce 2x–4x oversized images — we always downscale to this width.
|
|
440
|
+
SCREENSHOT_MAX_WIDTH = 800
|
|
441
|
+
# Hard limit on base64 size after downscaling. If still too large, reject.
|
|
442
|
+
SCREENSHOT_MAX_BASE64_BYTES = 100_000
|
|
443
|
+
|
|
444
|
+
private def do_user_screenshot(opts)
|
|
445
|
+
target_id = resolve_target_id(opts)
|
|
446
|
+
return target_id if target_id.is_a?(Hash)
|
|
447
|
+
|
|
448
|
+
# Always request PNG — chunky_png resizer works on PNG without native deps.
|
|
449
|
+
# full_page defaults to false to avoid tall images that are expensive in tokens.
|
|
450
|
+
# uid: if provided, screenshots only that element (much cheaper in tokens).
|
|
451
|
+
full_page = opts[:full_page] || opts["full_page"] || false
|
|
452
|
+
uid = opts[:ref] || opts["ref"]
|
|
453
|
+
|
|
454
|
+
call_args = { pageId: target_id.to_i, format: "png", fullPage: full_page }
|
|
455
|
+
call_args[:uid] = uid if uid
|
|
456
|
+
result = mcp_call("take_screenshot", call_args)
|
|
457
|
+
|
|
458
|
+
# MCP returns: { "content": [{ "type": "image", "mimeType": "image/png", "data": "<base64>" }] }
|
|
459
|
+
image_block = Array(result["content"]).find { |b| b.is_a?(Hash) && b["type"] == "image" }
|
|
460
|
+
|
|
461
|
+
unless image_block
|
|
462
|
+
text = extract_text_content(result)
|
|
463
|
+
return { action: "screenshot", success: true, profile: "user",
|
|
464
|
+
output: text.empty? ? "Screenshot captured (large image saved to temp file)." : text }
|
|
92
465
|
end
|
|
93
466
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if !result[:success] && session_closed_error?(result) && cfg.session_name
|
|
105
|
-
full_command = build_command(effective_command,
|
|
106
|
-
auto_connect: cfg.auto_connect,
|
|
107
|
-
session_name: nil,
|
|
108
|
-
headed: cfg.headed)
|
|
109
|
-
result = Shell.new.execute(command: full_command,
|
|
110
|
-
hard_timeout: BROWSER_COMMAND_TIMEOUT,
|
|
111
|
-
working_dir: working_dir)
|
|
467
|
+
image_data = image_block["data"]
|
|
468
|
+
|
|
469
|
+
# Downscale to SCREENSHOT_MAX_WIDTH using pure Ruby PNG resizer (no gems needed).
|
|
470
|
+
image_data = png_downscale_base64(image_data, SCREENSHOT_MAX_WIDTH)
|
|
471
|
+
|
|
472
|
+
if image_data.bytesize > SCREENSHOT_MAX_BASE64_BYTES
|
|
473
|
+
size_kb = image_data.bytesize / 1024
|
|
474
|
+
return { action: "screenshot", success: false, profile: "user",
|
|
475
|
+
output: "Screenshot too large after resize (#{size_kb}KB). " \
|
|
476
|
+
"Use action=snapshot instead — it provides the full accessibility tree without token overhead." }
|
|
112
477
|
end
|
|
113
478
|
|
|
114
|
-
|
|
479
|
+
{ action: "screenshot", success: true, profile: "user",
|
|
480
|
+
image_data: image_data, mime_type: "image/png",
|
|
481
|
+
output: "Screenshot captured." }
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
# PNG downscaler using chunky_png — minimal, reliable, zero native deps
|
|
486
|
+
#
|
|
487
|
+
# Accepts base64-encoded PNG, decodes it, and if wider than max_width
|
|
488
|
+
# downscales proportionally and re-encodes as PNG.
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
private def png_downscale_base64(b64, max_width)
|
|
491
|
+
require "chunky_png"
|
|
492
|
+
|
|
493
|
+
image = ChunkyPNG::Image.from_blob(Base64.strict_decode64(b64))
|
|
494
|
+
# return b64 if image.width <= max_width
|
|
495
|
+
|
|
496
|
+
src_w, src_h = image.width, image.height
|
|
497
|
+
before_kb = b64.bytesize / 1024
|
|
498
|
+
dst_h = (src_h * max_width.to_f / src_w).round
|
|
499
|
+
image.resample_nearest_neighbor!(max_width, dst_h)
|
|
500
|
+
result = Base64.strict_encode64(image.to_blob)
|
|
501
|
+
after_kb = result.bytesize / 1024
|
|
502
|
+
|
|
503
|
+
Clacky::Logger.error("screenshot resized",
|
|
504
|
+
from: "#{src_w}x#{src_h} (#{before_kb}KB)",
|
|
505
|
+
to: "#{max_width}x#{dst_h} (#{after_kb}KB)")
|
|
506
|
+
|
|
115
507
|
result
|
|
116
|
-
rescue StandardError => e
|
|
117
|
-
{ error: "Failed to run agent-browser: #{e.message}" }
|
|
118
508
|
end
|
|
119
509
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
510
|
+
# -----------------------------------------------------------------------
|
|
511
|
+
# Sandbox browser (agent-browser fallback — not Chrome MCP)
|
|
512
|
+
# -----------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
# -----------------------------------------------------------------------
|
|
515
|
+
# Chrome MCP — process management & JSON-RPC over stdio
|
|
516
|
+
# -----------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
# Returns the path to the system `node` binary, or nil if not found.
|
|
519
|
+
# Does NOT search nvm or other version managers — the user is responsible
|
|
520
|
+
# for ensuring the correct Node.js version is active in their PATH.
|
|
521
|
+
private def find_node_binary
|
|
522
|
+
path = `which node 2>/dev/null`.strip
|
|
523
|
+
return nil if path.empty? || !File.executable?(path)
|
|
524
|
+
|
|
525
|
+
path
|
|
123
526
|
end
|
|
124
527
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
528
|
+
# Returns the installed Node.js major version, or nil if not installed.
|
|
529
|
+
private def node_major_version
|
|
530
|
+
node = find_node_binary
|
|
531
|
+
return nil unless node
|
|
532
|
+
|
|
533
|
+
out = `#{node} --version 2>/dev/null`.strip # e.g. "v22.1.0"
|
|
534
|
+
out.gsub(/^v/, "").split(".").first.to_i
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Checks Node.js availability and version.
|
|
538
|
+
# Returns nil if everything is fine, or an error Hash with a user-facing message.
|
|
539
|
+
private def node_error
|
|
540
|
+
major = node_major_version
|
|
541
|
+
|
|
542
|
+
if major.nil?
|
|
543
|
+
return {
|
|
544
|
+
error: <<~MSG
|
|
545
|
+
⚠️ IMPORTANT — STOP and tell the user:
|
|
546
|
+
|
|
547
|
+
──────────────────────────────────────────────────
|
|
548
|
+
浏览器自动化需要 Node.js(>= #{MIN_NODE_MAJOR})。
|
|
549
|
+
|
|
550
|
+
系统未检测到 Node.js,请安装后重试:
|
|
551
|
+
https://nodejs.org/en/download
|
|
552
|
+
|
|
553
|
+
安装完成后告诉我,我来重试。
|
|
554
|
+
──────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
Do NOT fall back to web_search or any other workaround.
|
|
557
|
+
MSG
|
|
558
|
+
}
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
if major < MIN_NODE_MAJOR
|
|
562
|
+
return {
|
|
563
|
+
error: <<~MSG
|
|
564
|
+
⚠️ IMPORTANT — STOP and tell the user:
|
|
565
|
+
|
|
566
|
+
──────────────────────────────────────────────────
|
|
567
|
+
当前 Node.js 版本过低(v#{major}),需要 >= #{MIN_NODE_MAJOR}。
|
|
568
|
+
|
|
569
|
+
请升级 Node.js 后重试:
|
|
570
|
+
https://nodejs.org/en/download
|
|
571
|
+
|
|
572
|
+
升级完成后告诉我,我来重试。
|
|
573
|
+
──────────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
Do NOT fall back to web_search or any other workaround.
|
|
576
|
+
MSG
|
|
577
|
+
}
|
|
135
578
|
end
|
|
579
|
+
|
|
580
|
+
nil
|
|
136
581
|
end
|
|
137
582
|
|
|
138
|
-
|
|
139
|
-
|
|
583
|
+
# Build the command array for chrome-devtools-mcp.
|
|
584
|
+
# Public class method — called by BrowserManager so it doesn't need to
|
|
585
|
+
# duplicate the arg list.
|
|
586
|
+
# If user_data_dir is provided, appends --userDataDir.
|
|
587
|
+
def self.build_mcp_command(user_data_dir: nil)
|
|
588
|
+
args = CHROME_MCP_BASE_ARGS.dup
|
|
589
|
+
args += ["--userDataDir", user_data_dir.to_s] if user_data_dir && !user_data_dir.to_s.empty?
|
|
140
590
|
|
|
141
|
-
|
|
142
|
-
|
|
591
|
+
["chrome-devtools-mcp", *args]
|
|
592
|
+
end
|
|
143
593
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
594
|
+
# Delegate MCP tool call to BrowserManager singleton.
|
|
595
|
+
# BrowserManager owns the daemon process — ensures it's alive, handles
|
|
596
|
+
# the JSON-RPC protocol, and restarts on crash. Thread-safe.
|
|
597
|
+
private def mcp_call(tool_name, arguments = {}, user_data_dir: nil)
|
|
598
|
+
Clacky::BrowserManager.instance.mcp_call(tool_name, arguments)
|
|
599
|
+
end
|
|
147
600
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
exit_code: result[:exit_code]
|
|
152
|
-
}
|
|
601
|
+
# -----------------------------------------------------------------------
|
|
602
|
+
# MCP response extractors
|
|
603
|
+
# -----------------------------------------------------------------------
|
|
153
604
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
605
|
+
private def extract_pages(result)
|
|
606
|
+
return [] unless result.is_a?(Hash)
|
|
607
|
+
|
|
608
|
+
# Try structuredContent.pages first
|
|
609
|
+
structured = result["structuredContent"]
|
|
610
|
+
if structured.is_a?(Hash) && structured["pages"].is_a?(Array)
|
|
611
|
+
return structured["pages"].map do |p|
|
|
612
|
+
{ id: p["id"], url: p["url"], selected: p["selected"] == true }
|
|
613
|
+
end
|
|
159
614
|
end
|
|
160
615
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
616
|
+
# Fall back to text content parsing
|
|
617
|
+
text = extract_text_content(result)
|
|
618
|
+
parse_pages_from_text(text)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
private def extract_snapshot(result)
|
|
622
|
+
return {} unless result.is_a?(Hash)
|
|
164
623
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
624
|
+
structured = result["structuredContent"]
|
|
625
|
+
if structured.is_a?(Hash) && structured["snapshot"].is_a?(Hash)
|
|
626
|
+
return structured["snapshot"]
|
|
627
|
+
end
|
|
168
628
|
|
|
169
|
-
|
|
629
|
+
# Try content array
|
|
630
|
+
text = extract_text_content(result)
|
|
631
|
+
begin
|
|
632
|
+
JSON.parse(text)
|
|
633
|
+
rescue StandardError
|
|
634
|
+
{}
|
|
635
|
+
end
|
|
170
636
|
end
|
|
171
637
|
|
|
172
|
-
private
|
|
638
|
+
private def extract_message(result)
|
|
639
|
+
return "" unless result.is_a?(Hash)
|
|
640
|
+
|
|
641
|
+
structured = result["structuredContent"]
|
|
642
|
+
if structured.is_a?(Hash)
|
|
643
|
+
return structured["message"].to_s if structured["message"]
|
|
644
|
+
end
|
|
173
645
|
|
|
174
|
-
|
|
175
|
-
agent_browser_installed? && !agent_browser_outdated?
|
|
646
|
+
extract_text_content(result)
|
|
176
647
|
end
|
|
177
648
|
|
|
178
|
-
def
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
649
|
+
private def extract_text_content(result)
|
|
650
|
+
return "" unless result.is_a?(Hash)
|
|
651
|
+
|
|
652
|
+
content = result["content"]
|
|
653
|
+
return "" unless content.is_a?(Array)
|
|
654
|
+
|
|
655
|
+
content.filter_map do |entry|
|
|
656
|
+
entry["text"] if entry.is_a?(Hash) && entry["text"].is_a?(String)
|
|
657
|
+
end.join("\n")
|
|
183
658
|
end
|
|
184
659
|
|
|
185
|
-
def
|
|
186
|
-
|
|
187
|
-
|
|
660
|
+
private def parse_pages_from_text(text)
|
|
661
|
+
text.each_line.filter_map do |line|
|
|
662
|
+
m = line.match(/^\s*(\d+):\s+(.+?)(?:\s+\[(selected)\])?\s*$/i)
|
|
663
|
+
next unless m
|
|
664
|
+
{ id: m[1].to_i, url: m[2].strip, selected: !m[3].nil? }
|
|
665
|
+
end
|
|
188
666
|
end
|
|
189
667
|
|
|
190
|
-
def
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return false if version.nil? || version.empty?
|
|
194
|
-
Gem::Version.new(version) < Gem::Version.new(MIN_AGENT_BROWSER_VERSION)
|
|
195
|
-
rescue StandardError
|
|
196
|
-
false
|
|
668
|
+
private def format_tabs(pages)
|
|
669
|
+
return "No open tabs." if pages.empty?
|
|
670
|
+
pages.map { |p| "#{p[:id]}: #{p[:url]}#{p[:selected] ? ' [selected]' : ''}" }.join("\n")
|
|
197
671
|
end
|
|
198
672
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
673
|
+
# -----------------------------------------------------------------------
|
|
674
|
+
# Snapshot rendering (ChromeMcpSnapshotNode → AI text format)
|
|
675
|
+
# -----------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
INTERACTIVE_ROLES = %w[
|
|
678
|
+
button link textbox checkbox radio select combobox
|
|
679
|
+
menuitem option tab switch searchbox spinbutton
|
|
680
|
+
slider menuitemcheckbox menuitemradio
|
|
681
|
+
].freeze
|
|
682
|
+
|
|
683
|
+
STRUCTURAL_ROLES = %w[
|
|
684
|
+
generic none presentation group region section
|
|
685
|
+
].freeze
|
|
686
|
+
|
|
687
|
+
CONTENT_ROLES = %w[
|
|
688
|
+
heading paragraph text statictext image img
|
|
689
|
+
listitem term definition
|
|
690
|
+
].freeze
|
|
691
|
+
|
|
692
|
+
private def build_ai_snapshot(node, interactive: false, compact: false, max_depth: nil)
|
|
693
|
+
return "" unless node.is_a?(Hash) && !node.empty?
|
|
694
|
+
|
|
695
|
+
lines = []
|
|
696
|
+
refs = {}
|
|
697
|
+
visit_node(node, 0, lines, refs,
|
|
698
|
+
interactive: interactive,
|
|
699
|
+
compact: compact,
|
|
700
|
+
max_depth: max_depth)
|
|
701
|
+
lines.join("\n")
|
|
206
702
|
end
|
|
207
703
|
|
|
208
|
-
def
|
|
209
|
-
|
|
210
|
-
|
|
704
|
+
private def visit_node(node, depth, lines, refs, interactive:, compact:, max_depth:)
|
|
705
|
+
return if max_depth && depth > max_depth
|
|
706
|
+
|
|
707
|
+
role = node["role"].to_s.downcase.strip
|
|
708
|
+
role = "generic" if role.empty?
|
|
709
|
+
name = node["name"].to_s.strip
|
|
710
|
+
uid = node["id"].to_s.strip
|
|
711
|
+
val = node["value"]
|
|
712
|
+
desc = node["description"].to_s.strip
|
|
713
|
+
|
|
714
|
+
# Decide whether to render this node (but always recurse into children)
|
|
715
|
+
render = true
|
|
716
|
+
render = false if interactive && !INTERACTIVE_ROLES.include?(role)
|
|
717
|
+
render = false if compact && STRUCTURAL_ROLES.include?(role) && name.empty?
|
|
718
|
+
|
|
719
|
+
if render
|
|
720
|
+
line = "#{" " * (depth * 2)}- #{role}"
|
|
721
|
+
line += " \"#{escape_quoted(name)}\"" unless name.empty?
|
|
722
|
+
|
|
723
|
+
# Assign ref if interactive or named content role
|
|
724
|
+
if uid && !uid.empty? && (INTERACTIVE_ROLES.include?(role) ||
|
|
725
|
+
(CONTENT_ROLES.include?(role) && !name.empty?))
|
|
726
|
+
refs[uid] = { role: role, name: name }
|
|
727
|
+
line += " [ref=#{uid}]"
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
line += " value=\"#{escape_quoted(val.to_s)}\"" unless val.nil? || val.to_s.empty?
|
|
731
|
+
line += " description=\"#{escape_quoted(desc)}\"" unless desc.empty?
|
|
732
|
+
|
|
733
|
+
lines << line
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Always recurse into children regardless of whether this node was rendered
|
|
737
|
+
child_depth = render ? depth + 1 : depth
|
|
738
|
+
Array(node["children"]).each do |child|
|
|
739
|
+
visit_node(child, child_depth, lines, refs,
|
|
740
|
+
interactive: interactive,
|
|
741
|
+
compact: compact,
|
|
742
|
+
max_depth: max_depth)
|
|
743
|
+
end
|
|
211
744
|
end
|
|
212
745
|
|
|
213
|
-
def
|
|
214
|
-
|
|
215
|
-
cmd = command.strip.downcase
|
|
216
|
-
cmd == "snapshot" || cmd.start_with?("snapshot ")
|
|
746
|
+
private def escape_quoted(str)
|
|
747
|
+
str.to_s.gsub("\\", "\\\\").gsub('"', '\\"')
|
|
217
748
|
end
|
|
218
749
|
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
750
|
+
# -----------------------------------------------------------------------
|
|
751
|
+
# Parameter helpers
|
|
752
|
+
# -----------------------------------------------------------------------
|
|
753
|
+
|
|
754
|
+
private def require_url(opts)
|
|
755
|
+
url = opts[:url] || opts["url"] || ""
|
|
756
|
+
return { error: "url is required for this action" } if url.empty?
|
|
757
|
+
url
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
private def require_ref(ref)
|
|
761
|
+
return { error: "ref is required for this act kind (snapshot first to get refs)" } if ref.nil? || ref.to_s.empty?
|
|
762
|
+
ref.to_s
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
private def resolve_target_id(opts)
|
|
766
|
+
tid = opts[:target_id] || opts["target_id"]
|
|
767
|
+
if tid && !tid.to_s.empty?
|
|
768
|
+
return tid.to_s
|
|
769
|
+
end
|
|
770
|
+
# Auto-select the first available page
|
|
771
|
+
result = mcp_call("list_pages")
|
|
772
|
+
pages = extract_pages(result)
|
|
773
|
+
page = pages.find { |p| p[:selected] } || pages.first
|
|
774
|
+
return { error: "No open tabs found. Use action=open first." } unless page
|
|
775
|
+
page[:id].to_s
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# -----------------------------------------------------------------------
|
|
779
|
+
# Output helpers
|
|
780
|
+
# -----------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
private def compress_snapshot(output)
|
|
226
783
|
return output if output.empty?
|
|
227
784
|
|
|
228
785
|
lines = output.lines
|
|
@@ -234,38 +791,26 @@ module Clacky
|
|
|
234
791
|
end
|
|
235
792
|
|
|
236
793
|
removed = orig - filtered.size
|
|
237
|
-
|
|
794
|
+
if removed > 0
|
|
795
|
+
filtered << "\n[snapshot compressed: #{removed} lines removed]\n"
|
|
796
|
+
end
|
|
238
797
|
filtered.join
|
|
239
798
|
end
|
|
240
799
|
|
|
241
|
-
def
|
|
242
|
-
|
|
243
|
-
File.basename(first_word.to_s, ".*")
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def truncate_and_save(output, max_chars, _label, command_name)
|
|
247
|
-
return { content: "", temp_file: nil } if output.empty?
|
|
248
|
-
return { content: output, temp_file: nil } if output.length <= max_chars
|
|
249
|
-
|
|
250
|
-
lines = output.lines
|
|
251
|
-
return { content: output, temp_file: nil } if lines.length <= 2
|
|
800
|
+
private def truncate_output(output, max_chars)
|
|
801
|
+
return output if output.length <= max_chars
|
|
252
802
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
temp_file = File.join(temp_dir, "browser_#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
|
|
256
|
-
File.write(temp_file, output)
|
|
257
|
-
|
|
258
|
-
available = max_chars - 200
|
|
803
|
+
lines = output.lines
|
|
804
|
+
available = max_chars - 150
|
|
259
805
|
first_part = []
|
|
260
|
-
|
|
806
|
+
acc = 0
|
|
261
807
|
lines.each do |line|
|
|
262
|
-
break if
|
|
808
|
+
break if acc + line.length > available
|
|
263
809
|
first_part << line
|
|
264
|
-
|
|
810
|
+
acc += line.length
|
|
265
811
|
end
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
{ content: first_part.join + notice, temp_file: temp_file }
|
|
812
|
+
notice = "\n... [truncated: #{first_part.size}/#{lines.size} lines shown] ..."
|
|
813
|
+
first_part.join + notice
|
|
269
814
|
end
|
|
270
815
|
end
|
|
271
816
|
end
|