@loxia-labs/loxia-autopilot-one 1.0.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/LICENSE +267 -0
- package/README.md +509 -0
- package/bin/cli.js +117 -0
- package/package.json +94 -0
- package/scripts/install-scanners.js +236 -0
- package/src/analyzers/CSSAnalyzer.js +297 -0
- package/src/analyzers/ConfigValidator.js +690 -0
- package/src/analyzers/ESLintAnalyzer.js +320 -0
- package/src/analyzers/JavaScriptAnalyzer.js +261 -0
- package/src/analyzers/PrettierFormatter.js +247 -0
- package/src/analyzers/PythonAnalyzer.js +266 -0
- package/src/analyzers/SecurityAnalyzer.js +729 -0
- package/src/analyzers/TypeScriptAnalyzer.js +247 -0
- package/src/analyzers/codeCloneDetector/analyzer.js +344 -0
- package/src/analyzers/codeCloneDetector/detector.js +203 -0
- package/src/analyzers/codeCloneDetector/index.js +160 -0
- package/src/analyzers/codeCloneDetector/parser.js +199 -0
- package/src/analyzers/codeCloneDetector/reporter.js +148 -0
- package/src/analyzers/codeCloneDetector/scanner.js +59 -0
- package/src/core/agentPool.js +1474 -0
- package/src/core/agentScheduler.js +2147 -0
- package/src/core/contextManager.js +709 -0
- package/src/core/messageProcessor.js +732 -0
- package/src/core/orchestrator.js +548 -0
- package/src/core/stateManager.js +877 -0
- package/src/index.js +631 -0
- package/src/interfaces/cli.js +549 -0
- package/src/interfaces/webServer.js +2162 -0
- package/src/modules/fileExplorer/controller.js +280 -0
- package/src/modules/fileExplorer/index.js +37 -0
- package/src/modules/fileExplorer/middleware.js +92 -0
- package/src/modules/fileExplorer/routes.js +125 -0
- package/src/modules/fileExplorer/types.js +44 -0
- package/src/services/aiService.js +1232 -0
- package/src/services/apiKeyManager.js +164 -0
- package/src/services/benchmarkService.js +366 -0
- package/src/services/budgetService.js +539 -0
- package/src/services/contextInjectionService.js +247 -0
- package/src/services/conversationCompactionService.js +637 -0
- package/src/services/errorHandler.js +810 -0
- package/src/services/fileAttachmentService.js +544 -0
- package/src/services/modelRouterService.js +366 -0
- package/src/services/modelsService.js +322 -0
- package/src/services/qualityInspector.js +796 -0
- package/src/services/tokenCountingService.js +536 -0
- package/src/tools/agentCommunicationTool.js +1344 -0
- package/src/tools/agentDelayTool.js +485 -0
- package/src/tools/asyncToolManager.js +604 -0
- package/src/tools/baseTool.js +800 -0
- package/src/tools/browserTool.js +920 -0
- package/src/tools/cloneDetectionTool.js +621 -0
- package/src/tools/dependencyResolverTool.js +1215 -0
- package/src/tools/fileContentReplaceTool.js +875 -0
- package/src/tools/fileSystemTool.js +1107 -0
- package/src/tools/fileTreeTool.js +853 -0
- package/src/tools/imageTool.js +901 -0
- package/src/tools/importAnalyzerTool.js +1060 -0
- package/src/tools/jobDoneTool.js +248 -0
- package/src/tools/seekTool.js +956 -0
- package/src/tools/staticAnalysisTool.js +1778 -0
- package/src/tools/taskManagerTool.js +2873 -0
- package/src/tools/terminalTool.js +2304 -0
- package/src/tools/webTool.js +1430 -0
- package/src/types/agent.js +519 -0
- package/src/types/contextReference.js +972 -0
- package/src/types/conversation.js +730 -0
- package/src/types/toolCommand.js +747 -0
- package/src/utilities/attachmentValidator.js +292 -0
- package/src/utilities/configManager.js +582 -0
- package/src/utilities/constants.js +722 -0
- package/src/utilities/directoryAccessManager.js +535 -0
- package/src/utilities/fileProcessor.js +307 -0
- package/src/utilities/logger.js +436 -0
- package/src/utilities/tagParser.js +1246 -0
- package/src/utilities/toolConstants.js +317 -0
- package/web-ui/build/index.html +15 -0
- package/web-ui/build/logo.png +0 -0
- package/web-ui/build/logo2.png +0 -0
- package/web-ui/build/static/index-CjkkcnFA.js +344 -0
- package/web-ui/build/static/index-Dy2bYbOa.css +1 -0
|
@@ -0,0 +1,1430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebTool - Web browsing and automation with Puppeteer
|
|
3
|
+
*
|
|
4
|
+
* Purpose:
|
|
5
|
+
* - Search the web using known search engines
|
|
6
|
+
* - Fetch web content in various formats
|
|
7
|
+
* - Interactive browser automation with command chaining
|
|
8
|
+
* - Tab management with agent isolation
|
|
9
|
+
* - Screenshot capture and AI-powered analysis
|
|
10
|
+
* - Mouse and keyboard event simulation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { BaseTool } from './baseTool.js';
|
|
14
|
+
import TagParser from '../utilities/tagParser.js';
|
|
15
|
+
import puppeteer from 'puppeteer';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import fs from 'fs/promises';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
TOOL_STATUS,
|
|
22
|
+
SYSTEM_DEFAULTS
|
|
23
|
+
} from '../utilities/constants.js';
|
|
24
|
+
|
|
25
|
+
class WebTool extends BaseTool {
|
|
26
|
+
constructor(config = {}, logger = null) {
|
|
27
|
+
super(config, logger);
|
|
28
|
+
|
|
29
|
+
// Tool metadata
|
|
30
|
+
this.requiresProject = false;
|
|
31
|
+
this.isAsync = true;
|
|
32
|
+
|
|
33
|
+
// Browser instance (singleton per system)
|
|
34
|
+
this.browser = null;
|
|
35
|
+
this.browserInitializing = false;
|
|
36
|
+
|
|
37
|
+
// Tab tracking: Map<agentId, Map<tabName, tabInfo>>
|
|
38
|
+
this.agentTabs = new Map();
|
|
39
|
+
|
|
40
|
+
// Known search engines
|
|
41
|
+
this.searchEngines = [
|
|
42
|
+
{
|
|
43
|
+
name: 'google',
|
|
44
|
+
url: 'https://www.google.com/search?q=',
|
|
45
|
+
searchSelector: 'input[name="q"]',
|
|
46
|
+
submitSelector: 'input[type="submit"], button[type="submit"]',
|
|
47
|
+
resultsSelector: '#search .g a, #search a[href]',
|
|
48
|
+
waitSelector: '#search'
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'bing',
|
|
52
|
+
url: 'https://www.bing.com/search?q=',
|
|
53
|
+
searchSelector: 'input[name="q"]',
|
|
54
|
+
submitSelector: 'input[type="submit"]',
|
|
55
|
+
resultsSelector: '.b_algo a',
|
|
56
|
+
waitSelector: '#b_results'
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'duckduckgo',
|
|
60
|
+
url: 'https://duckduckgo.com/?q=',
|
|
61
|
+
searchSelector: 'input[name="q"]',
|
|
62
|
+
submitSelector: 'button[type="submit"]',
|
|
63
|
+
resultsSelector: '.result__a, a[data-testid="result-title-a"]',
|
|
64
|
+
waitSelector: '#links, [data-testid="mainline"]'
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Configuration
|
|
69
|
+
this.TAB_IDLE_TIMEOUT = config.tabIdleTimeout || 60 * 60 * 1000; // 1 hour
|
|
70
|
+
this.CLEANUP_INTERVAL = config.cleanupInterval || 5 * 60 * 1000; // 5 minutes
|
|
71
|
+
this.DEFAULT_TIMEOUT = config.defaultTimeout || 60000; // 60 seconds
|
|
72
|
+
this.TEMP_DIR = config.tempDir || path.join(os.tmpdir(), 'webtool-screenshots');
|
|
73
|
+
|
|
74
|
+
// Start cleanup timer
|
|
75
|
+
this.cleanupTimer = null;
|
|
76
|
+
this.startCleanupTimer();
|
|
77
|
+
|
|
78
|
+
// Ensure temp directory exists
|
|
79
|
+
this.ensureTempDir();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get tool description for LLM consumption
|
|
84
|
+
* @returns {string} Tool description
|
|
85
|
+
*/
|
|
86
|
+
getDescription() {
|
|
87
|
+
return `
|
|
88
|
+
Web Tool: Browse, search, and automate web interactions using a real browser (Puppeteer).
|
|
89
|
+
|
|
90
|
+
IMPORTANT: This tool supports command chaining - nest multiple actions together to execute them sequentially without waiting for responses between each action.
|
|
91
|
+
|
|
92
|
+
USAGE:
|
|
93
|
+
[tool id="web"]
|
|
94
|
+
<operation>search|fetch|interactive</operation>
|
|
95
|
+
<!-- Operation-specific content -->
|
|
96
|
+
[/tool]
|
|
97
|
+
|
|
98
|
+
ALTERNATIVE JSON FORMAT:
|
|
99
|
+
\`\`\`json
|
|
100
|
+
{
|
|
101
|
+
"toolId": "web",
|
|
102
|
+
"operation": "search|fetch|interactive",
|
|
103
|
+
"parameters": { ... }
|
|
104
|
+
}
|
|
105
|
+
\`\`\`
|
|
106
|
+
|
|
107
|
+
═══════════════════════════════════════════════════════════════
|
|
108
|
+
OPERATION 1: SEARCH THE WEB
|
|
109
|
+
═══════════════════════════════════════════════════════════════
|
|
110
|
+
|
|
111
|
+
Search using real browsers on known search engines (Google, Bing, DuckDuckGo).
|
|
112
|
+
|
|
113
|
+
XML SYNTAX:
|
|
114
|
+
[tool id="web"]
|
|
115
|
+
<operation>search</operation>
|
|
116
|
+
<query>puppeteer web scraping tutorial</query>
|
|
117
|
+
<engine>google</engine> <!-- Optional: google|bing|duckduckgo, default: google -->
|
|
118
|
+
<max-results>10</max-results> <!-- Optional, default: 10 -->
|
|
119
|
+
[/tool]
|
|
120
|
+
|
|
121
|
+
JSON SYNTAX:
|
|
122
|
+
\`\`\`json
|
|
123
|
+
{
|
|
124
|
+
"toolId": "web",
|
|
125
|
+
"operation": "search",
|
|
126
|
+
"query": "puppeteer web scraping tutorial",
|
|
127
|
+
"engine": "google",
|
|
128
|
+
"maxResults": 10
|
|
129
|
+
}
|
|
130
|
+
\`\`\`
|
|
131
|
+
|
|
132
|
+
OUTPUT: List of URLs with titles and descriptions
|
|
133
|
+
|
|
134
|
+
═══════════════════════════════════════════════════════════════
|
|
135
|
+
OPERATION 2: FETCH WEB CONTENT
|
|
136
|
+
═══════════════════════════════════════════════════════════════
|
|
137
|
+
|
|
138
|
+
Fetch content from a URL in various formats.
|
|
139
|
+
|
|
140
|
+
XML SYNTAX:
|
|
141
|
+
[tool id="web"]
|
|
142
|
+
<operation>fetch</operation>
|
|
143
|
+
<url>https://example.com</url>
|
|
144
|
+
<format>title,text,links</format> <!-- Options: title|text|links|html|console -->
|
|
145
|
+
[/tool]
|
|
146
|
+
|
|
147
|
+
JSON SYNTAX:
|
|
148
|
+
\`\`\`json
|
|
149
|
+
{
|
|
150
|
+
"toolId": "web",
|
|
151
|
+
"operation": "fetch",
|
|
152
|
+
"url": "https://example.com",
|
|
153
|
+
"formats": ["title", "text", "links", "html", "console"]
|
|
154
|
+
}
|
|
155
|
+
\`\`\`
|
|
156
|
+
|
|
157
|
+
FORMAT OPTIONS:
|
|
158
|
+
- title: Page title
|
|
159
|
+
- text: Plain text content (no HTML)
|
|
160
|
+
- links: All links on page
|
|
161
|
+
- html: Full HTML source
|
|
162
|
+
- console: Browser console messages
|
|
163
|
+
|
|
164
|
+
OUTPUT: Object with requested content formats
|
|
165
|
+
|
|
166
|
+
═══════════════════════════════════════════════════════════════
|
|
167
|
+
OPERATION 3: INTERACTIVE BROWSER AUTOMATION
|
|
168
|
+
═══════════════════════════════════════════════════════════════
|
|
169
|
+
|
|
170
|
+
Control a real browser with command chaining for complex workflows.
|
|
171
|
+
|
|
172
|
+
XML SYNTAX (RECOMMENDED FOR CHAINING):
|
|
173
|
+
[tool id="web"]
|
|
174
|
+
<operation>interactive</operation>
|
|
175
|
+
<headless>true</headless> <!-- true|false, default: true -->
|
|
176
|
+
<actions>
|
|
177
|
+
<open-tab name="search">
|
|
178
|
+
<navigate>https://github.com/trending</navigate>
|
|
179
|
+
<wait-for selector=".Box-row" timeout="5000" />
|
|
180
|
+
<click selector=".Box-row:first-child a" />
|
|
181
|
+
<wait-for selector="#readme" />
|
|
182
|
+
<screenshot format="file" path="readme.png" />
|
|
183
|
+
<analyze-screenshot>What is the main topic of this README?</analyze-screenshot>
|
|
184
|
+
<extract-text selector="#readme" />
|
|
185
|
+
<get-source />
|
|
186
|
+
</open-tab>
|
|
187
|
+
<open-tab name="docs">
|
|
188
|
+
<navigate>https://docs.example.com</navigate>
|
|
189
|
+
<type selector="input.search" text="installation" />
|
|
190
|
+
<press key="Enter" />
|
|
191
|
+
<extract-links selector="a.doc-link" />
|
|
192
|
+
</open-tab>
|
|
193
|
+
<list-tabs />
|
|
194
|
+
<close-tab name="search" />
|
|
195
|
+
</actions>
|
|
196
|
+
[/tool]
|
|
197
|
+
|
|
198
|
+
JSON SYNTAX (ALTERNATIVE):
|
|
199
|
+
\`\`\`json
|
|
200
|
+
{
|
|
201
|
+
"toolId": "web",
|
|
202
|
+
"operation": "interactive",
|
|
203
|
+
"headless": true,
|
|
204
|
+
"actions": [
|
|
205
|
+
{
|
|
206
|
+
"type": "open-tab",
|
|
207
|
+
"name": "search",
|
|
208
|
+
"url": "https://github.com/trending",
|
|
209
|
+
"nestedActions": [
|
|
210
|
+
{"type": "wait-for", "selector": ".Box-row", "timeout": 5000},
|
|
211
|
+
{"type": "click", "selector": ".Box-row:first-child a"},
|
|
212
|
+
{"type": "screenshot", "format": "base64"},
|
|
213
|
+
{"type": "extract-text", "selector": "#readme"}
|
|
214
|
+
]
|
|
215
|
+
},
|
|
216
|
+
{"type": "list-tabs"},
|
|
217
|
+
{"type": "close-tab", "name": "search"}
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
\`\`\`
|
|
221
|
+
|
|
222
|
+
SUPPORTED ACTIONS:
|
|
223
|
+
- open-tab: Open new tab with nested actions
|
|
224
|
+
- close-tab: Close specific tab
|
|
225
|
+
- switch-tab: Switch to existing tab
|
|
226
|
+
- list-tabs: List all active tabs for this agent
|
|
227
|
+
- navigate: Go to URL
|
|
228
|
+
- click: Click element (left|right|middle)
|
|
229
|
+
- type: Type text into element
|
|
230
|
+
- press: Press keyboard key
|
|
231
|
+
- wait-for: Wait for element to appear
|
|
232
|
+
- screenshot: Capture screenshot (file|base64)
|
|
233
|
+
- analyze-screenshot: AI analysis of current page
|
|
234
|
+
- extract-text: Extract text from selector
|
|
235
|
+
- extract-links: Extract all links
|
|
236
|
+
- get-source: Get HTML source
|
|
237
|
+
- get-console: Get console messages
|
|
238
|
+
- scroll: Scroll page
|
|
239
|
+
- hover: Hover over element
|
|
240
|
+
|
|
241
|
+
MOUSE EVENTS:
|
|
242
|
+
<click selector=".button" button="left" /> <!-- left|right|middle -->
|
|
243
|
+
<hover selector=".menu" />
|
|
244
|
+
<mouse-move selector=".element" />
|
|
245
|
+
|
|
246
|
+
KEYBOARD EVENTS:
|
|
247
|
+
<type selector="input" text="Hello World" />
|
|
248
|
+
<press key="Enter" />
|
|
249
|
+
<press key="Control+C" /> <!-- Supports modifier keys -->
|
|
250
|
+
|
|
251
|
+
SCREENSHOT OPTIONS:
|
|
252
|
+
<screenshot format="file" path="screenshot.png" /> <!-- Save to project dir -->
|
|
253
|
+
<screenshot format="file" /> <!-- Save to temp dir -->
|
|
254
|
+
<screenshot format="base64" /> <!-- Return as base64 string -->
|
|
255
|
+
|
|
256
|
+
AI SCREENSHOT ANALYSIS:
|
|
257
|
+
<analyze-screenshot>What products are visible on this page?</analyze-screenshot>
|
|
258
|
+
<analyze-screenshot model="gpt-4-vision">Describe the layout</analyze-screenshot>
|
|
259
|
+
|
|
260
|
+
TAB MANAGEMENT:
|
|
261
|
+
- Tabs are agent-isolated (each agent has its own tabs)
|
|
262
|
+
- Tabs auto-close after 1 hour of inactivity
|
|
263
|
+
- Tab names must be unique per agent
|
|
264
|
+
- Use descriptive names for easy identification
|
|
265
|
+
|
|
266
|
+
═══════════════════════════════════════════════════════════════
|
|
267
|
+
COMMAND CHAINING BENEFITS
|
|
268
|
+
═══════════════════════════════════════════════════════════════
|
|
269
|
+
|
|
270
|
+
Instead of:
|
|
271
|
+
1. Open tab → wait for response
|
|
272
|
+
2. Navigate → wait for response
|
|
273
|
+
3. Click → wait for response
|
|
274
|
+
4. Screenshot → wait for response
|
|
275
|
+
|
|
276
|
+
Do this (ONE REQUEST):
|
|
277
|
+
<open-tab name="task">
|
|
278
|
+
<navigate>URL</navigate>
|
|
279
|
+
<click selector=".button" />
|
|
280
|
+
<screenshot />
|
|
281
|
+
</open-tab>
|
|
282
|
+
|
|
283
|
+
All actions execute sequentially in one operation!
|
|
284
|
+
|
|
285
|
+
═══════════════════════════════════════════════════════════════
|
|
286
|
+
EXAMPLES
|
|
287
|
+
═══════════════════════════════════════════════════════════════
|
|
288
|
+
|
|
289
|
+
EXAMPLE 1: Search and analyze results
|
|
290
|
+
[tool id="web"]
|
|
291
|
+
<operation>interactive</operation>
|
|
292
|
+
<headless>true</headless>
|
|
293
|
+
<actions>
|
|
294
|
+
<open-tab name="search">
|
|
295
|
+
<navigate>https://google.com</navigate>
|
|
296
|
+
<type selector="input[name=q]">best web scraping tools 2025</type>
|
|
297
|
+
<press key="Enter" />
|
|
298
|
+
<wait-for selector="#search" />
|
|
299
|
+
<screenshot format="file" path="search-results.png" />
|
|
300
|
+
<analyze-screenshot>List the top 3 tools mentioned</analyze-screenshot>
|
|
301
|
+
<extract-links selector="#search .g a" />
|
|
302
|
+
</open-tab>
|
|
303
|
+
</actions>
|
|
304
|
+
[/tool]
|
|
305
|
+
|
|
306
|
+
EXAMPLE 2: Multi-tab workflow
|
|
307
|
+
[tool id="web"]
|
|
308
|
+
<operation>interactive</operation>
|
|
309
|
+
<actions>
|
|
310
|
+
<open-tab name="github">
|
|
311
|
+
<navigate>https://github.com/trending</navigate>
|
|
312
|
+
<extract-links selector=".Box-row a" />
|
|
313
|
+
</open-tab>
|
|
314
|
+
<open-tab name="npm">
|
|
315
|
+
<navigate>https://npmjs.com/package/puppeteer</navigate>
|
|
316
|
+
<extract-text selector=".package-description" />
|
|
317
|
+
</open-tab>
|
|
318
|
+
<list-tabs />
|
|
319
|
+
</actions>
|
|
320
|
+
[/tool]
|
|
321
|
+
|
|
322
|
+
EXAMPLE 3: Form interaction
|
|
323
|
+
[tool id="web"]
|
|
324
|
+
<operation>interactive</operation>
|
|
325
|
+
<headless>false</headless> <!-- Visible browser for debugging -->
|
|
326
|
+
<actions>
|
|
327
|
+
<open-tab name="form">
|
|
328
|
+
<navigate>https://example.com/contact</navigate>
|
|
329
|
+
<type selector="#name" text="John Doe" />
|
|
330
|
+
<type selector="#email" text="john@example.com" />
|
|
331
|
+
<type selector="#message" text="Hello!" />
|
|
332
|
+
<click selector="#submit" />
|
|
333
|
+
<wait-for selector=".success-message" />
|
|
334
|
+
<screenshot format="base64" />
|
|
335
|
+
</open-tab>
|
|
336
|
+
</actions>
|
|
337
|
+
[/tool]
|
|
338
|
+
|
|
339
|
+
EXAMPLE 4: Simple fetch
|
|
340
|
+
[tool id="web"]
|
|
341
|
+
<operation>fetch</operation>
|
|
342
|
+
<url>https://example.com</url>
|
|
343
|
+
<format>title,text,links</format>
|
|
344
|
+
[/tool]
|
|
345
|
+
|
|
346
|
+
EXAMPLE 5: Quick search
|
|
347
|
+
[tool id="web"]
|
|
348
|
+
<operation>search</operation>
|
|
349
|
+
<query>openai gpt-4 api documentation</query>
|
|
350
|
+
<engine>google</engine>
|
|
351
|
+
<max-results>5</max-results>
|
|
352
|
+
[/tool]
|
|
353
|
+
|
|
354
|
+
SECURITY NOTES:
|
|
355
|
+
- Browser runs in isolated context per agent
|
|
356
|
+
- Tabs auto-close after 1 hour of inactivity
|
|
357
|
+
- Screenshots stored in temp directory with auto-cleanup
|
|
358
|
+
- No access to local file system beyond allowed directories
|
|
359
|
+
|
|
360
|
+
BEST PRACTICES:
|
|
361
|
+
- Use command chaining to minimize round-trips
|
|
362
|
+
- Use descriptive tab names
|
|
363
|
+
- Close tabs when done to free resources
|
|
364
|
+
- Use headless mode for better performance
|
|
365
|
+
- Use visible mode only for debugging
|
|
366
|
+
`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Parse parameters from tool command content
|
|
371
|
+
* @param {string} content - Raw tool command content
|
|
372
|
+
* @returns {Object} Parsed parameters
|
|
373
|
+
*/
|
|
374
|
+
parseParameters(content) {
|
|
375
|
+
try {
|
|
376
|
+
// Try JSON first
|
|
377
|
+
if (content.trim().startsWith('{')) {
|
|
378
|
+
return JSON.parse(content);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Parse XML-style tags
|
|
382
|
+
const params = {};
|
|
383
|
+
|
|
384
|
+
// Extract operation
|
|
385
|
+
const operationMatches = TagParser.extractContent(content, 'operation');
|
|
386
|
+
if (operationMatches.length > 0) {
|
|
387
|
+
params.operation = operationMatches[0].trim();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Extract based on operation
|
|
391
|
+
switch (params.operation) {
|
|
392
|
+
case 'search':
|
|
393
|
+
params.query = TagParser.extractContent(content, 'query')[0]?.trim();
|
|
394
|
+
params.engine = TagParser.extractContent(content, 'engine')[0]?.trim() || 'google';
|
|
395
|
+
const maxResults = TagParser.extractContent(content, 'max-results')[0]?.trim();
|
|
396
|
+
params.maxResults = maxResults ? parseInt(maxResults, 10) : 10;
|
|
397
|
+
break;
|
|
398
|
+
|
|
399
|
+
case 'fetch':
|
|
400
|
+
params.url = TagParser.extractContent(content, 'url')[0]?.trim();
|
|
401
|
+
const formatStr = TagParser.extractContent(content, 'format')[0]?.trim();
|
|
402
|
+
params.formats = formatStr ? formatStr.split(',').map(f => f.trim()) : ['title', 'text'];
|
|
403
|
+
break;
|
|
404
|
+
|
|
405
|
+
case 'interactive':
|
|
406
|
+
const headlessStr = TagParser.extractContent(content, 'headless')[0]?.trim();
|
|
407
|
+
params.headless = headlessStr !== 'false'; // Default true
|
|
408
|
+
|
|
409
|
+
// Extract actions block
|
|
410
|
+
const actionsContent = TagParser.extractContent(content, 'actions')[0];
|
|
411
|
+
if (actionsContent) {
|
|
412
|
+
params.actions = this.parseActions(actionsContent);
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
params.rawContent = content.trim();
|
|
418
|
+
return params;
|
|
419
|
+
|
|
420
|
+
} catch (error) {
|
|
421
|
+
throw new Error(`Failed to parse web tool parameters: ${error.message}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Parse actions from XML content
|
|
427
|
+
* @param {string} content - Actions XML content
|
|
428
|
+
* @returns {Array} Parsed actions
|
|
429
|
+
* @private
|
|
430
|
+
*/
|
|
431
|
+
parseActions(content) {
|
|
432
|
+
const actions = [];
|
|
433
|
+
|
|
434
|
+
// Parse open-tab actions
|
|
435
|
+
const openTabRegex = /<open-tab[^>]*name="([^"]+)"[^>]*>([\s\S]*?)<\/open-tab>/g;
|
|
436
|
+
let match;
|
|
437
|
+
|
|
438
|
+
while ((match = openTabRegex.exec(content)) !== null) {
|
|
439
|
+
const [, name, nestedContent] = match;
|
|
440
|
+
const url = TagParser.extractContent(nestedContent, 'navigate')[0]?.trim();
|
|
441
|
+
|
|
442
|
+
actions.push({
|
|
443
|
+
type: 'open-tab',
|
|
444
|
+
name,
|
|
445
|
+
url,
|
|
446
|
+
nestedActions: this.parseNestedActions(nestedContent)
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Parse other actions (close-tab, switch-tab, list-tabs, etc.)
|
|
451
|
+
const simpleActions = [
|
|
452
|
+
'close-tab', 'switch-tab', 'list-tabs', 'navigate',
|
|
453
|
+
'click', 'type', 'press', 'wait-for', 'screenshot',
|
|
454
|
+
'analyze-screenshot', 'extract-text', 'extract-links',
|
|
455
|
+
'get-source', 'get-console', 'scroll', 'hover', 'mouse-move'
|
|
456
|
+
];
|
|
457
|
+
|
|
458
|
+
for (const actionType of simpleActions) {
|
|
459
|
+
const regex = new RegExp(`<${actionType}([^>]*)>([^<]*)<\/${actionType}>`, 'g');
|
|
460
|
+
let actionMatch;
|
|
461
|
+
|
|
462
|
+
while ((actionMatch = regex.exec(content)) !== null) {
|
|
463
|
+
const [, attrs, value] = actionMatch;
|
|
464
|
+
const action = { type: actionType };
|
|
465
|
+
|
|
466
|
+
// Parse attributes
|
|
467
|
+
const attrRegex = /(\w+(?:-\w+)*)="([^"]*)"/g;
|
|
468
|
+
let attrMatch;
|
|
469
|
+
while ((attrMatch = attrRegex.exec(attrs)) !== null) {
|
|
470
|
+
action[attrMatch[1]] = attrMatch[2];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Add value if present
|
|
474
|
+
if (value && value.trim()) {
|
|
475
|
+
action.value = value.trim();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
actions.push(action);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return actions;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Parse nested actions within a tab
|
|
487
|
+
* @param {string} content - Nested actions content
|
|
488
|
+
* @returns {Array} Parsed nested actions
|
|
489
|
+
* @private
|
|
490
|
+
*/
|
|
491
|
+
parseNestedActions(content) {
|
|
492
|
+
const actions = [];
|
|
493
|
+
|
|
494
|
+
const actionTypes = [
|
|
495
|
+
'navigate', 'click', 'type', 'press', 'wait-for', 'screenshot',
|
|
496
|
+
'analyze-screenshot', 'extract-text', 'extract-links',
|
|
497
|
+
'get-source', 'get-console', 'scroll', 'hover', 'mouse-move'
|
|
498
|
+
];
|
|
499
|
+
|
|
500
|
+
for (const actionType of actionTypes) {
|
|
501
|
+
const regex = new RegExp(`<${actionType}([^>]*)>([^<]*)<\/${actionType}>|<${actionType}([^>]*)\/>`, 'g');
|
|
502
|
+
let match;
|
|
503
|
+
|
|
504
|
+
while ((match = regex.exec(content)) !== null) {
|
|
505
|
+
const [, attrs1, value, attrs2] = match;
|
|
506
|
+
const attrs = attrs1 || attrs2 || '';
|
|
507
|
+
const action = { type: actionType };
|
|
508
|
+
|
|
509
|
+
// Parse attributes
|
|
510
|
+
const attrRegex = /(\w+(?:-\w+)*)="([^"]*)"/g;
|
|
511
|
+
let attrMatch;
|
|
512
|
+
while ((attrMatch = attrRegex.exec(attrs)) !== null) {
|
|
513
|
+
action[attrMatch[1]] = attrMatch[2];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Add value if present
|
|
517
|
+
if (value && value.trim()) {
|
|
518
|
+
action.value = value.trim();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
actions.push(action);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return actions;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Get required parameters based on operation
|
|
530
|
+
* @returns {Array<string>} Array of required parameter names
|
|
531
|
+
*/
|
|
532
|
+
getRequiredParameters() {
|
|
533
|
+
return ['operation'];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Custom parameter validation
|
|
538
|
+
* @param {Object} params - Parameters to validate
|
|
539
|
+
* @returns {Object} Validation result
|
|
540
|
+
*/
|
|
541
|
+
customValidateParameters(params) {
|
|
542
|
+
const errors = [];
|
|
543
|
+
|
|
544
|
+
if (!['search', 'fetch', 'interactive'].includes(params.operation)) {
|
|
545
|
+
errors.push('operation must be one of: search, fetch, interactive');
|
|
546
|
+
return { valid: false, errors };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
switch (params.operation) {
|
|
550
|
+
case 'search':
|
|
551
|
+
if (!params.query) {
|
|
552
|
+
errors.push('query is required for search operation');
|
|
553
|
+
}
|
|
554
|
+
break;
|
|
555
|
+
|
|
556
|
+
case 'fetch':
|
|
557
|
+
if (!params.url) {
|
|
558
|
+
errors.push('url is required for fetch operation');
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
|
|
562
|
+
case 'interactive':
|
|
563
|
+
if (!params.actions || !Array.isArray(params.actions) || params.actions.length === 0) {
|
|
564
|
+
errors.push('actions array is required for interactive operation');
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
valid: errors.length === 0,
|
|
571
|
+
errors
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Execute tool with parsed parameters
|
|
577
|
+
* @param {Object} params - Parsed parameters
|
|
578
|
+
* @param {Object} context - Execution context
|
|
579
|
+
* @returns {Promise<Object>} Execution result
|
|
580
|
+
*/
|
|
581
|
+
async execute(params, context) {
|
|
582
|
+
const { operation } = params;
|
|
583
|
+
const { agentId } = context;
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
// Ensure browser is initialized
|
|
587
|
+
await this.ensureBrowser();
|
|
588
|
+
|
|
589
|
+
let result;
|
|
590
|
+
|
|
591
|
+
switch (operation) {
|
|
592
|
+
case 'search':
|
|
593
|
+
result = await this.search(params.query, {
|
|
594
|
+
engine: params.engine || 'google',
|
|
595
|
+
maxResults: params.maxResults || 10,
|
|
596
|
+
agentId
|
|
597
|
+
});
|
|
598
|
+
break;
|
|
599
|
+
|
|
600
|
+
case 'fetch':
|
|
601
|
+
result = await this.fetch(params.url, {
|
|
602
|
+
formats: params.formats || ['title', 'text'],
|
|
603
|
+
agentId
|
|
604
|
+
});
|
|
605
|
+
break;
|
|
606
|
+
|
|
607
|
+
case 'interactive':
|
|
608
|
+
result = await this.interactive(params.actions, {
|
|
609
|
+
headless: params.headless !== false, // Default true
|
|
610
|
+
agentId,
|
|
611
|
+
context
|
|
612
|
+
});
|
|
613
|
+
break;
|
|
614
|
+
|
|
615
|
+
default:
|
|
616
|
+
throw new Error(`Unknown operation: ${operation}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
success: true,
|
|
621
|
+
operation,
|
|
622
|
+
result,
|
|
623
|
+
toolUsed: 'web'
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
} catch (error) {
|
|
627
|
+
this.logger?.error('Web tool execution failed', {
|
|
628
|
+
operation,
|
|
629
|
+
error: error.message,
|
|
630
|
+
agentId
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
success: false,
|
|
635
|
+
operation,
|
|
636
|
+
error: error.message,
|
|
637
|
+
toolUsed: 'web'
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Ensure browser is initialized
|
|
644
|
+
* @private
|
|
645
|
+
*/
|
|
646
|
+
async ensureBrowser() {
|
|
647
|
+
if (this.browser && this.browser.isConnected()) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (this.browserInitializing) {
|
|
652
|
+
// Wait for browser to finish initializing
|
|
653
|
+
while (this.browserInitializing) {
|
|
654
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
this.browserInitializing = true;
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
this.logger?.info('Initializing Puppeteer browser');
|
|
663
|
+
|
|
664
|
+
this.browser = await puppeteer.launch({
|
|
665
|
+
headless: 'new', // Use new headless mode
|
|
666
|
+
args: [
|
|
667
|
+
'--no-sandbox',
|
|
668
|
+
'--disable-setuid-sandbox',
|
|
669
|
+
'--disable-dev-shm-usage',
|
|
670
|
+
'--disable-accelerated-2d-canvas',
|
|
671
|
+
'--disable-gpu',
|
|
672
|
+
'--disable-blink-features=AutomationControlled',
|
|
673
|
+
'--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
674
|
+
]
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
this.logger?.info('Puppeteer browser initialized successfully');
|
|
678
|
+
|
|
679
|
+
} catch (error) {
|
|
680
|
+
this.logger?.error('Failed to initialize browser', { error: error.message });
|
|
681
|
+
throw new Error(`Browser initialization failed: ${error.message}`);
|
|
682
|
+
} finally {
|
|
683
|
+
this.browserInitializing = false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Search the web using a known search engine
|
|
689
|
+
* @param {string} query - Search query
|
|
690
|
+
* @param {Object} options - Search options
|
|
691
|
+
* @returns {Promise<Object>} Search results
|
|
692
|
+
*/
|
|
693
|
+
async search(query, options = {}) {
|
|
694
|
+
const { engine = 'google', maxResults = 10, agentId } = options;
|
|
695
|
+
|
|
696
|
+
// Validate query
|
|
697
|
+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
698
|
+
throw new Error('Search query is required and must be a non-empty string');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const searchEngine = this.searchEngines.find(e => e.name === engine);
|
|
702
|
+
if (!searchEngine) {
|
|
703
|
+
throw new Error(`Unknown search engine: ${engine}. Available: ${this.searchEngines.map(e => e.name).join(', ')}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Ensure browser is initialized
|
|
707
|
+
await this.ensureBrowser();
|
|
708
|
+
|
|
709
|
+
this.logger?.info('Performing web search', { query, engine, agentId });
|
|
710
|
+
|
|
711
|
+
// Create temporary page for search
|
|
712
|
+
const page = await this.browser.newPage();
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
// Navigate to search engine
|
|
716
|
+
const searchUrl = `${searchEngine.url}${encodeURIComponent(query)}`;
|
|
717
|
+
await page.goto(searchUrl, { waitUntil: 'networkidle2', timeout: this.DEFAULT_TIMEOUT });
|
|
718
|
+
|
|
719
|
+
// Wait for results
|
|
720
|
+
await page.waitForSelector(searchEngine.waitSelector, { timeout: this.DEFAULT_TIMEOUT });
|
|
721
|
+
|
|
722
|
+
// Extract results
|
|
723
|
+
const results = await page.evaluate((selector, max) => {
|
|
724
|
+
const links = Array.from(document.querySelectorAll(selector));
|
|
725
|
+
return links.slice(0, max).map(link => ({
|
|
726
|
+
url: link.href,
|
|
727
|
+
title: link.textContent.trim(),
|
|
728
|
+
description: link.closest('.g, .b_algo, .result')?.textContent.trim() || ''
|
|
729
|
+
})).filter(result => result.url && result.url.startsWith('http'));
|
|
730
|
+
}, searchEngine.resultsSelector, maxResults);
|
|
731
|
+
|
|
732
|
+
this.logger?.info('Search completed', { resultsCount: results.length, agentId });
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
success: true,
|
|
736
|
+
query,
|
|
737
|
+
engine,
|
|
738
|
+
resultsCount: results.length,
|
|
739
|
+
results
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
} finally {
|
|
743
|
+
await page.close();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Fetch web content in various formats
|
|
749
|
+
* @param {string} url - URL to fetch
|
|
750
|
+
* @param {Object} options - Fetch options
|
|
751
|
+
* @returns {Promise<Object>} Fetched content
|
|
752
|
+
*/
|
|
753
|
+
async fetch(url, options = {}) {
|
|
754
|
+
const { formats = ['title', 'text'], agentId } = options;
|
|
755
|
+
|
|
756
|
+
// Ensure browser is initialized
|
|
757
|
+
await this.ensureBrowser();
|
|
758
|
+
|
|
759
|
+
this.logger?.info('Fetching web content', { url, formats, agentId });
|
|
760
|
+
|
|
761
|
+
// Create temporary page
|
|
762
|
+
const page = await this.browser.newPage();
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
// Listen for console messages if requested
|
|
766
|
+
const consoleMessages = [];
|
|
767
|
+
if (formats.includes('console')) {
|
|
768
|
+
page.on('console', msg => {
|
|
769
|
+
consoleMessages.push({
|
|
770
|
+
type: msg.type(),
|
|
771
|
+
text: msg.text()
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Navigate to URL
|
|
777
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: this.DEFAULT_TIMEOUT });
|
|
778
|
+
|
|
779
|
+
const result = { url };
|
|
780
|
+
|
|
781
|
+
// Extract requested formats
|
|
782
|
+
for (const format of formats) {
|
|
783
|
+
switch (format) {
|
|
784
|
+
case 'title':
|
|
785
|
+
result.title = await page.title();
|
|
786
|
+
break;
|
|
787
|
+
|
|
788
|
+
case 'text':
|
|
789
|
+
result.text = await page.evaluate(() => document.body.innerText);
|
|
790
|
+
break;
|
|
791
|
+
|
|
792
|
+
case 'links':
|
|
793
|
+
result.links = await page.evaluate(() => {
|
|
794
|
+
return Array.from(document.querySelectorAll('a[href]')).map(a => ({
|
|
795
|
+
href: a.href,
|
|
796
|
+
text: a.textContent.trim()
|
|
797
|
+
}));
|
|
798
|
+
});
|
|
799
|
+
break;
|
|
800
|
+
|
|
801
|
+
case 'html':
|
|
802
|
+
result.html = await page.content();
|
|
803
|
+
break;
|
|
804
|
+
|
|
805
|
+
case 'console':
|
|
806
|
+
result.consoleMessages = consoleMessages;
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
this.logger?.info('Fetch completed', { url, formats, agentId });
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
success: true,
|
|
815
|
+
...result
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
} finally {
|
|
819
|
+
await page.close();
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Interactive browser automation with command chaining
|
|
825
|
+
* @param {Array} actions - Array of actions to execute
|
|
826
|
+
* @param {Object} options - Options
|
|
827
|
+
* @returns {Promise<Object>} Results of all actions
|
|
828
|
+
*/
|
|
829
|
+
async interactive(actions, options = {}) {
|
|
830
|
+
const { headless = true, agentId, context } = options;
|
|
831
|
+
|
|
832
|
+
// Ensure browser is initialized
|
|
833
|
+
await this.ensureBrowser();
|
|
834
|
+
|
|
835
|
+
this.logger?.info('Starting interactive session', {
|
|
836
|
+
actionsCount: actions.length,
|
|
837
|
+
headless,
|
|
838
|
+
agentId
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const results = [];
|
|
842
|
+
|
|
843
|
+
// Initialize agent tabs if not exists
|
|
844
|
+
if (!this.agentTabs.has(agentId)) {
|
|
845
|
+
this.agentTabs.set(agentId, new Map());
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
849
|
+
|
|
850
|
+
for (const action of actions) {
|
|
851
|
+
try {
|
|
852
|
+
let actionResult;
|
|
853
|
+
|
|
854
|
+
switch (action.type) {
|
|
855
|
+
case 'open-tab':
|
|
856
|
+
actionResult = await this.openTab(agentId, action.name, action.url, headless, action.nestedActions, context);
|
|
857
|
+
break;
|
|
858
|
+
|
|
859
|
+
case 'close-tab':
|
|
860
|
+
actionResult = await this.closeTab(agentId, action.name);
|
|
861
|
+
break;
|
|
862
|
+
|
|
863
|
+
case 'switch-tab':
|
|
864
|
+
actionResult = await this.switchTab(agentId, action.name);
|
|
865
|
+
break;
|
|
866
|
+
|
|
867
|
+
case 'list-tabs':
|
|
868
|
+
actionResult = await this.listTabs(agentId);
|
|
869
|
+
break;
|
|
870
|
+
|
|
871
|
+
default:
|
|
872
|
+
// For actions that need a tab context, we need to specify which tab
|
|
873
|
+
// For now, we'll skip these at the top level
|
|
874
|
+
actionResult = {
|
|
875
|
+
success: false,
|
|
876
|
+
error: `Action ${action.type} must be executed within a tab context (use open-tab with nestedActions)`
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
results.push({
|
|
881
|
+
action: action.type,
|
|
882
|
+
...actionResult
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
} catch (error) {
|
|
886
|
+
this.logger?.error('Action failed', {
|
|
887
|
+
action: action.type,
|
|
888
|
+
error: error.message,
|
|
889
|
+
agentId
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
results.push({
|
|
893
|
+
action: action.type,
|
|
894
|
+
success: false,
|
|
895
|
+
error: error.message
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return {
|
|
901
|
+
success: results.every(r => r.success !== false),
|
|
902
|
+
actionsExecuted: results.length,
|
|
903
|
+
results
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Open a new tab with nested actions
|
|
909
|
+
* @param {string} agentId - Agent identifier
|
|
910
|
+
* @param {string} tabName - Unique tab name
|
|
911
|
+
* @param {string} url - Initial URL
|
|
912
|
+
* @param {boolean} headless - Headless mode
|
|
913
|
+
* @param {Array} nestedActions - Actions to execute in this tab
|
|
914
|
+
* @param {Object} context - Execution context
|
|
915
|
+
* @returns {Promise<Object>} Result
|
|
916
|
+
*/
|
|
917
|
+
async openTab(agentId, tabName, url, headless, nestedActions = [], context = {}) {
|
|
918
|
+
// Initialize agent tabs if not exists
|
|
919
|
+
if (!this.agentTabs.has(agentId)) {
|
|
920
|
+
this.agentTabs.set(agentId, new Map());
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
924
|
+
|
|
925
|
+
// Check if tab already exists
|
|
926
|
+
if (agentTabsMap.has(tabName)) {
|
|
927
|
+
throw new Error(`Tab '${tabName}' already exists for agent ${agentId}`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
this.logger?.info('Opening tab', { agentId, tabName, url, headless });
|
|
931
|
+
|
|
932
|
+
// Create new page
|
|
933
|
+
const page = await this.browser.newPage();
|
|
934
|
+
|
|
935
|
+
// Set viewport
|
|
936
|
+
await page.setViewport({ width: 1280, height: 720 });
|
|
937
|
+
|
|
938
|
+
// Track console messages
|
|
939
|
+
const consoleMessages = [];
|
|
940
|
+
page.on('console', msg => {
|
|
941
|
+
consoleMessages.push({
|
|
942
|
+
type: msg.type(),
|
|
943
|
+
text: msg.text(),
|
|
944
|
+
timestamp: Date.now()
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// Store tab info
|
|
949
|
+
const tabInfo = {
|
|
950
|
+
page,
|
|
951
|
+
url,
|
|
952
|
+
lastActivity: Date.now(),
|
|
953
|
+
headless,
|
|
954
|
+
consoleMessages,
|
|
955
|
+
name: tabName
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
agentTabsMap.set(tabName, tabInfo);
|
|
959
|
+
|
|
960
|
+
const results = [];
|
|
961
|
+
|
|
962
|
+
try {
|
|
963
|
+
// Navigate to initial URL if provided
|
|
964
|
+
if (url) {
|
|
965
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: this.DEFAULT_TIMEOUT });
|
|
966
|
+
tabInfo.url = url;
|
|
967
|
+
tabInfo.lastActivity = Date.now();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Execute nested actions
|
|
971
|
+
for (const action of nestedActions) {
|
|
972
|
+
const actionResult = await this.executeTabAction(page, action, tabInfo, context);
|
|
973
|
+
results.push({
|
|
974
|
+
action: action.type,
|
|
975
|
+
...actionResult
|
|
976
|
+
});
|
|
977
|
+
tabInfo.lastActivity = Date.now();
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return {
|
|
981
|
+
success: true,
|
|
982
|
+
tabName,
|
|
983
|
+
url: tabInfo.url,
|
|
984
|
+
actionsExecuted: results.length,
|
|
985
|
+
results
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
} catch (error) {
|
|
989
|
+
this.logger?.error('Failed to open tab', {
|
|
990
|
+
agentId,
|
|
991
|
+
tabName,
|
|
992
|
+
error: error.message
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// Clean up on error
|
|
996
|
+
await page.close();
|
|
997
|
+
agentTabsMap.delete(tabName);
|
|
998
|
+
|
|
999
|
+
throw error;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Execute an action in a tab context
|
|
1005
|
+
* @param {Page} page - Puppeteer page
|
|
1006
|
+
* @param {Object} action - Action to execute
|
|
1007
|
+
* @param {Object} tabInfo - Tab information
|
|
1008
|
+
* @param {Object} context - Execution context
|
|
1009
|
+
* @returns {Promise<Object>} Action result
|
|
1010
|
+
* @private
|
|
1011
|
+
*/
|
|
1012
|
+
async executeTabAction(page, action, tabInfo, context) {
|
|
1013
|
+
switch (action.type) {
|
|
1014
|
+
case 'navigate':
|
|
1015
|
+
await page.goto(action.value || action.url, {
|
|
1016
|
+
waitUntil: 'networkidle2',
|
|
1017
|
+
timeout: this.DEFAULT_TIMEOUT
|
|
1018
|
+
});
|
|
1019
|
+
tabInfo.url = page.url();
|
|
1020
|
+
return { success: true, url: tabInfo.url };
|
|
1021
|
+
|
|
1022
|
+
case 'click':
|
|
1023
|
+
await page.click(action.selector, {
|
|
1024
|
+
button: action.button || 'left'
|
|
1025
|
+
});
|
|
1026
|
+
return { success: true, selector: action.selector };
|
|
1027
|
+
|
|
1028
|
+
case 'type':
|
|
1029
|
+
await page.type(action.selector, action.text || action.value);
|
|
1030
|
+
return { success: true, selector: action.selector, text: action.text };
|
|
1031
|
+
|
|
1032
|
+
case 'press':
|
|
1033
|
+
await page.keyboard.press(action.key || action.value);
|
|
1034
|
+
return { success: true, key: action.key };
|
|
1035
|
+
|
|
1036
|
+
case 'wait-for':
|
|
1037
|
+
const timeout = action.timeout ? parseInt(action.timeout, 10) : this.DEFAULT_TIMEOUT;
|
|
1038
|
+
await page.waitForSelector(action.selector, { timeout });
|
|
1039
|
+
return { success: true, selector: action.selector };
|
|
1040
|
+
|
|
1041
|
+
case 'screenshot':
|
|
1042
|
+
return await this.takeScreenshot(page, action, context);
|
|
1043
|
+
|
|
1044
|
+
case 'analyze-screenshot':
|
|
1045
|
+
return await this.analyzeScreenshot(page, action.value, context);
|
|
1046
|
+
|
|
1047
|
+
case 'extract-text':
|
|
1048
|
+
const text = await page.evaluate((sel) => {
|
|
1049
|
+
const element = document.querySelector(sel);
|
|
1050
|
+
return element ? element.innerText : null;
|
|
1051
|
+
}, action.selector);
|
|
1052
|
+
return { success: true, selector: action.selector, text };
|
|
1053
|
+
|
|
1054
|
+
case 'extract-links':
|
|
1055
|
+
const links = await page.evaluate((sel) => {
|
|
1056
|
+
const elements = document.querySelectorAll(sel);
|
|
1057
|
+
return Array.from(elements).map(a => ({
|
|
1058
|
+
href: a.href,
|
|
1059
|
+
text: a.textContent.trim()
|
|
1060
|
+
}));
|
|
1061
|
+
}, action.selector);
|
|
1062
|
+
return { success: true, selector: action.selector, links };
|
|
1063
|
+
|
|
1064
|
+
case 'get-source':
|
|
1065
|
+
const html = await page.content();
|
|
1066
|
+
return { success: true, html };
|
|
1067
|
+
|
|
1068
|
+
case 'get-console':
|
|
1069
|
+
return {
|
|
1070
|
+
success: true,
|
|
1071
|
+
consoleMessages: [...tabInfo.consoleMessages]
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
case 'scroll':
|
|
1075
|
+
await page.evaluate((sel) => {
|
|
1076
|
+
if (sel) {
|
|
1077
|
+
document.querySelector(sel)?.scrollIntoView();
|
|
1078
|
+
} else {
|
|
1079
|
+
window.scrollTo(0, document.body.scrollHeight);
|
|
1080
|
+
}
|
|
1081
|
+
}, action.selector);
|
|
1082
|
+
return { success: true };
|
|
1083
|
+
|
|
1084
|
+
case 'hover':
|
|
1085
|
+
await page.hover(action.selector);
|
|
1086
|
+
return { success: true, selector: action.selector };
|
|
1087
|
+
|
|
1088
|
+
case 'mouse-move':
|
|
1089
|
+
await page.hover(action.selector);
|
|
1090
|
+
return { success: true, selector: action.selector };
|
|
1091
|
+
|
|
1092
|
+
default:
|
|
1093
|
+
throw new Error(`Unknown action type: ${action.type}`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Take screenshot of page
|
|
1099
|
+
* @param {Page} page - Puppeteer page
|
|
1100
|
+
* @param {Object} options - Screenshot options
|
|
1101
|
+
* @param {Object} context - Execution context
|
|
1102
|
+
* @returns {Promise<Object>} Screenshot result
|
|
1103
|
+
* @private
|
|
1104
|
+
*/
|
|
1105
|
+
async takeScreenshot(page, options, context) {
|
|
1106
|
+
const format = options.format || 'file';
|
|
1107
|
+
const screenshotPath = options.path;
|
|
1108
|
+
|
|
1109
|
+
if (format === 'base64') {
|
|
1110
|
+
const screenshot = await page.screenshot({ encoding: 'base64' });
|
|
1111
|
+
return {
|
|
1112
|
+
success: true,
|
|
1113
|
+
format: 'base64',
|
|
1114
|
+
screenshot
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// File format
|
|
1119
|
+
let filePath;
|
|
1120
|
+
|
|
1121
|
+
if (screenshotPath) {
|
|
1122
|
+
// Save to project directory if path is provided
|
|
1123
|
+
const projectDir = context.directoryAccess?.workingDirectory || context.projectDir || process.cwd();
|
|
1124
|
+
filePath = path.isAbsolute(screenshotPath)
|
|
1125
|
+
? screenshotPath
|
|
1126
|
+
: path.join(projectDir, screenshotPath);
|
|
1127
|
+
} else {
|
|
1128
|
+
// Save to temp directory
|
|
1129
|
+
const filename = `screenshot-${Date.now()}.png`;
|
|
1130
|
+
filePath = path.join(this.TEMP_DIR, filename);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
await page.screenshot({ path: filePath });
|
|
1134
|
+
|
|
1135
|
+
return {
|
|
1136
|
+
success: true,
|
|
1137
|
+
format: 'file',
|
|
1138
|
+
path: filePath
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Analyze screenshot using AI vision model
|
|
1144
|
+
* @param {Page} page - Puppeteer page
|
|
1145
|
+
* @param {string} question - Question for AI
|
|
1146
|
+
* @param {Object} context - Execution context
|
|
1147
|
+
* @returns {Promise<Object>} Analysis result
|
|
1148
|
+
* @private
|
|
1149
|
+
*/
|
|
1150
|
+
async analyzeScreenshot(page, question, context) {
|
|
1151
|
+
// Take screenshot as base64
|
|
1152
|
+
const screenshot = await page.screenshot({ encoding: 'base64' });
|
|
1153
|
+
|
|
1154
|
+
// Get AI service from context
|
|
1155
|
+
const aiService = context.aiService;
|
|
1156
|
+
if (!aiService) {
|
|
1157
|
+
throw new Error('AI service not available for screenshot analysis');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
this.logger?.info('Analyzing screenshot with AI', {
|
|
1161
|
+
question: question.substring(0, 100),
|
|
1162
|
+
agentId: context.agentId
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
try {
|
|
1166
|
+
// Use vision model (prefer o3 if available, fallback to gpt-4-vision)
|
|
1167
|
+
const model = 'o3'; // Will be mapped by AI service
|
|
1168
|
+
|
|
1169
|
+
// Create message with image
|
|
1170
|
+
const response = await aiService.sendMessage(
|
|
1171
|
+
model,
|
|
1172
|
+
question,
|
|
1173
|
+
{
|
|
1174
|
+
agentId: context.agentId,
|
|
1175
|
+
images: [`data:image/png;base64,${screenshot}`],
|
|
1176
|
+
apiKey: context.apiKey,
|
|
1177
|
+
customApiKeys: context.customApiKeys,
|
|
1178
|
+
platformProvided: context.platformProvided
|
|
1179
|
+
}
|
|
1180
|
+
);
|
|
1181
|
+
|
|
1182
|
+
return {
|
|
1183
|
+
success: true,
|
|
1184
|
+
question,
|
|
1185
|
+
analysis: response.content,
|
|
1186
|
+
model: response.model || model
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
this.logger?.error('Screenshot analysis failed', {
|
|
1191
|
+
error: error.message,
|
|
1192
|
+
agentId: context.agentId
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
throw new Error(`Screenshot analysis failed: ${error.message}`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Close a tab
|
|
1201
|
+
* @param {string} agentId - Agent identifier
|
|
1202
|
+
* @param {string} tabName - Tab name to close
|
|
1203
|
+
* @returns {Promise<Object>} Result
|
|
1204
|
+
*/
|
|
1205
|
+
async closeTab(agentId, tabName) {
|
|
1206
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
1207
|
+
if (!agentTabsMap || !agentTabsMap.has(tabName)) {
|
|
1208
|
+
throw new Error(`Tab '${tabName}' not found for agent ${agentId}`);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const tabInfo = agentTabsMap.get(tabName);
|
|
1212
|
+
|
|
1213
|
+
this.logger?.info('Closing tab', { agentId, tabName });
|
|
1214
|
+
|
|
1215
|
+
await tabInfo.page.close();
|
|
1216
|
+
agentTabsMap.delete(tabName);
|
|
1217
|
+
|
|
1218
|
+
return {
|
|
1219
|
+
success: true,
|
|
1220
|
+
tabName,
|
|
1221
|
+
message: `Tab '${tabName}' closed`
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Switch to an existing tab
|
|
1227
|
+
* @param {string} agentId - Agent identifier
|
|
1228
|
+
* @param {string} tabName - Tab name to switch to
|
|
1229
|
+
* @returns {Promise<Object>} Result
|
|
1230
|
+
*/
|
|
1231
|
+
async switchTab(agentId, tabName) {
|
|
1232
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
1233
|
+
if (!agentTabsMap || !agentTabsMap.has(tabName)) {
|
|
1234
|
+
throw new Error(`Tab '${tabName}' not found for agent ${agentId}`);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const tabInfo = agentTabsMap.get(tabName);
|
|
1238
|
+
tabInfo.lastActivity = Date.now();
|
|
1239
|
+
|
|
1240
|
+
return {
|
|
1241
|
+
success: true,
|
|
1242
|
+
tabName,
|
|
1243
|
+
url: tabInfo.url,
|
|
1244
|
+
message: `Switched to tab '${tabName}'`
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* List all active tabs for an agent
|
|
1250
|
+
* @param {string} agentId - Agent identifier
|
|
1251
|
+
* @returns {Promise<Object>} List of tabs
|
|
1252
|
+
*/
|
|
1253
|
+
async listTabs(agentId) {
|
|
1254
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
1255
|
+
|
|
1256
|
+
if (!agentTabsMap || agentTabsMap.size === 0) {
|
|
1257
|
+
return {
|
|
1258
|
+
success: true,
|
|
1259
|
+
tabCount: 0,
|
|
1260
|
+
tabs: [],
|
|
1261
|
+
message: 'No active tabs'
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const tabs = [];
|
|
1266
|
+
for (const [name, info] of agentTabsMap.entries()) {
|
|
1267
|
+
tabs.push({
|
|
1268
|
+
name,
|
|
1269
|
+
url: info.url,
|
|
1270
|
+
idleTime: Date.now() - info.lastActivity,
|
|
1271
|
+
headless: info.headless
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
return {
|
|
1276
|
+
success: true,
|
|
1277
|
+
tabCount: tabs.length,
|
|
1278
|
+
tabs
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Start cleanup timer for idle tabs
|
|
1284
|
+
* @private
|
|
1285
|
+
*/
|
|
1286
|
+
startCleanupTimer() {
|
|
1287
|
+
if (this.cleanupTimer) {
|
|
1288
|
+
clearInterval(this.cleanupTimer);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
this.cleanupTimer = setInterval(() => {
|
|
1292
|
+
this.cleanupIdleTabs();
|
|
1293
|
+
}, this.CLEANUP_INTERVAL);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Cleanup idle tabs (1-hour timeout)
|
|
1298
|
+
* @private
|
|
1299
|
+
*/
|
|
1300
|
+
async cleanupIdleTabs() {
|
|
1301
|
+
const now = Date.now();
|
|
1302
|
+
const tabsToClose = [];
|
|
1303
|
+
|
|
1304
|
+
for (const [agentId, agentTabsMap] of this.agentTabs.entries()) {
|
|
1305
|
+
for (const [tabName, tabInfo] of agentTabsMap.entries()) {
|
|
1306
|
+
const idleTime = now - tabInfo.lastActivity;
|
|
1307
|
+
|
|
1308
|
+
if (idleTime > this.TAB_IDLE_TIMEOUT) {
|
|
1309
|
+
tabsToClose.push({ agentId, tabName, tabInfo });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (tabsToClose.length > 0) {
|
|
1315
|
+
this.logger?.info('Cleaning up idle tabs', {
|
|
1316
|
+
count: tabsToClose.length
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
for (const { agentId, tabName, tabInfo } of tabsToClose) {
|
|
1320
|
+
try {
|
|
1321
|
+
await tabInfo.page.close();
|
|
1322
|
+
this.agentTabs.get(agentId).delete(tabName);
|
|
1323
|
+
this.logger?.debug('Closed idle tab', { agentId, tabName });
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
this.logger?.error('Failed to close idle tab', {
|
|
1326
|
+
agentId,
|
|
1327
|
+
tabName,
|
|
1328
|
+
error: error.message
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Cleanup all tabs for an agent (called when agent is deleted)
|
|
1337
|
+
* @param {string} agentId - Agent identifier
|
|
1338
|
+
* @returns {Promise<Object>} Cleanup result
|
|
1339
|
+
*/
|
|
1340
|
+
async cleanupAgent(agentId) {
|
|
1341
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
1342
|
+
|
|
1343
|
+
if (!agentTabsMap) {
|
|
1344
|
+
return {
|
|
1345
|
+
success: true,
|
|
1346
|
+
agentId,
|
|
1347
|
+
closedTabs: 0,
|
|
1348
|
+
message: 'No tabs to clean up'
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
this.logger?.info('Cleaning up agent tabs', {
|
|
1353
|
+
agentId,
|
|
1354
|
+
tabCount: agentTabsMap.size
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
let closedCount = 0;
|
|
1358
|
+
|
|
1359
|
+
for (const [tabName, tabInfo] of agentTabsMap.entries()) {
|
|
1360
|
+
try {
|
|
1361
|
+
await tabInfo.page.close();
|
|
1362
|
+
closedCount++;
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
this.logger?.error('Failed to close tab during cleanup', {
|
|
1365
|
+
agentId,
|
|
1366
|
+
tabName,
|
|
1367
|
+
error: error.message
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
this.agentTabs.delete(agentId);
|
|
1373
|
+
|
|
1374
|
+
return {
|
|
1375
|
+
success: true,
|
|
1376
|
+
agentId,
|
|
1377
|
+
closedTabs: closedCount,
|
|
1378
|
+
message: `Closed ${closedCount} tabs for agent ${agentId}`
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Ensure temp directory exists
|
|
1384
|
+
* @private
|
|
1385
|
+
*/
|
|
1386
|
+
async ensureTempDir() {
|
|
1387
|
+
try {
|
|
1388
|
+
await fs.mkdir(this.TEMP_DIR, { recursive: true });
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
this.logger?.warn('Failed to create temp directory', {
|
|
1391
|
+
path: this.TEMP_DIR,
|
|
1392
|
+
error: error.message
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Cleanup resources
|
|
1399
|
+
*/
|
|
1400
|
+
async cleanup() {
|
|
1401
|
+
// Stop cleanup timer
|
|
1402
|
+
if (this.cleanupTimer) {
|
|
1403
|
+
clearInterval(this.cleanupTimer);
|
|
1404
|
+
this.cleanupTimer = null;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Close all tabs
|
|
1408
|
+
for (const [agentId] of this.agentTabs.entries()) {
|
|
1409
|
+
await this.cleanupAgent(agentId);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Close browser
|
|
1413
|
+
if (this.browser) {
|
|
1414
|
+
await this.browser.close();
|
|
1415
|
+
this.browser = null;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Clean temp directory
|
|
1419
|
+
try {
|
|
1420
|
+
await fs.rm(this.TEMP_DIR, { recursive: true, force: true });
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
this.logger?.warn('Failed to clean temp directory', {
|
|
1423
|
+
path: this.TEMP_DIR,
|
|
1424
|
+
error: error.message
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
export default WebTool;
|