diff --git a/examples/013/README.md b/examples/013/README.md new file mode 100644 index 00000000000..4150411f800 --- /dev/null +++ b/examples/013/README.md @@ -0,0 +1,30 @@ +# Browser agent example + +## Setup for local headed browsing session + +### Tab 1 +``` +ockam +``` + +### Tab 2 +``` +cd node_server +npm install +npx playwright install +node server.js +``` +This will start a local playwright session in headed mode. + +It will also emit the command to create an outlet, copy that command. + +### Tab 3 +- wait until the `ockam` command from tab 1 has started the zone +- paste and run the command from step 2 + +## next steps + +Tab 2 and tab 3 can be left as is. + +The main `ockam` session from tab 1 will work after either k8s restarts the container or after a manual restart. + diff --git a/examples/013/images/main/Dockerfile b/examples/013/images/main/Dockerfile new file mode 100644 index 00000000000..95706220fbf --- /dev/null +++ b/examples/013/images/main/Dockerfile @@ -0,0 +1,9 @@ +FROM ghcr.io/build-trust/ockam-python-dev:latest AS dev +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +FROM ghcr.io/build-trust/ockam-python:latest +COPY --from=dev /app/venv venv +COPY main.py ./ +COPY index.html ./ +ENTRYPOINT ["python", "main.py"] \ No newline at end of file diff --git a/examples/013/images/main/index.html b/examples/013/images/main/index.html new file mode 100644 index 00000000000..6130b9a7967 --- /dev/null +++ b/examples/013/images/main/index.html @@ -0,0 +1,287 @@ + + + + + + + + + + + + +
+ + Streaming? + +

+    
+ + + + + \ No newline at end of file diff --git a/examples/013/images/main/main.py b/examples/013/images/main/main.py new file mode 100644 index 00000000000..f91fa0cb07a --- /dev/null +++ b/examples/013/images/main/main.py @@ -0,0 +1,425 @@ +from datetime import datetime +from sys import argv +from ockam import Agent, Model, Node, Tool, set_log_levels +from playwright.async_api import async_playwright + +import asyncio +import re + +# set_log_levels("agent=debug,ockam_node=info,ockam=info,LiteLLM=DEBUG") +set_log_levels("DEBUG") + + +class Browser: + def __init__(self, playwright, browser): + self.playwright = playwright + self.browser = browser + + async def new_context(self): + return await self.browser.new_context() + + async def stop(self): + await self.browser.close() + await self.playwright.stop() + + @staticmethod + async def start(address): + playwright = await async_playwright().start() + browser = await playwright.chromium.connect(address) + return Browser(playwright, browser) + + +class BrowserSession: + def __init__(self, browser, context): + self.browser = browser + self.context = context + self.pages = [] + self.locators = [] + + def tools(self): + + async def bring_to_front(page_index: int = 0) -> dict: + """ + Brings the page at the given index to the front. + """ + await self.pages[page_index].bring_to_front() + return {"status": "ok"} + + async def click(page_index: int = 0) -> dict: + """ + Clicks on the locator for the page at the given page index. + """ + await self.locators[page_index].click() + return {"status": "ok"} + + async def click_nth(page_index: int = 0, nth: int = 1) -> dict: + """ + Clicks on the nth element that was matched by the locator + for the page at the given page index. + """ + nth = nth - 1 + await self.locators[page_index].nth(nth).click() + return {"status": "ok"} + + async def current_iso8601_utc_time() -> str: + """ + Returns the current UTC time in ISO 8601 format. + """ + return datetime.utcnow().isoformat() + "Z" + + async def enter(page_index: int = 0) -> dict: + """ + Press enter anywhere on the page at the given page index. + """ + await self.pages[page_index].keyboard.press('Enter') + await self.pages[page_index].wait_for_load_state("domcontentloaded") + await asyncio.sleep(2) + return {"status": "ok"} + + async def fill(value: str, page_index: int = 0) -> dict: + """ + Fills the element specified by the current locator + for the page at the given index with the given value. + """ + await self.locators[page_index].fill(value) + return {"status": "ok"} + + async def fill_active(value: str, page_index: int = 0) -> dict: + """ + Fills the active element on the page at the given + index with the given value. + + Returns true if the element was filled, false otherwise. + """ + page = self.pages[page_index] + active = await page.evaluate_handle("() => document.activeElement") + element = active.as_element() + if element: + await element.fill(value) + return {"status": "ok", "filled": True} + + selector = page.locator("input[autofocus]") + count = await selector.count() + if count == 1: + await selector.fill(value) + return {"status": "ok", "filled": True} + + return {"status": "ok", "filled": False} + + async def filter(text: str, page_index: int = 0) -> dict: + """ + Filters the current locator for the page at the given index + by applying a has_text filter to the current locator + + Returns the count of elements currently matched by the locator. + """ + locator = self.locators[page_index].filter(has_text=text) + count = await locator.count() + if count == 0: + return {"status": "error", "message": f"No elements found with text: {text}"} + else: + self.locators[page_index] = locator + return {"status": "ok", "count": count} + + async def goto(url: str, page_index: int = 0) -> dict: + """ + Go to the given url for the page at the given index and + reset the locator on that page. + """ + if page_index >= len(self.pages): + result = await new_tab() + page_index = result["page_index"] + + page = self.pages[page_index] + # Ignore timeout errors when navigating + try: + await page.goto(url, timeout=5000) + except Exception as e: + None + await asyncio.sleep(1) + self.locators[page_index] = page.locator("body") + return {"status": "ok", "url": page.url} + + async def locate(selector: str, page_index: int = 0) -> dict: + """ + Sets the locator for the page at the given index + with the given selector. + + Returns the count of elements currently matched by the locator. + """ + locator = self.pages[page_index].locator(selector) + count = await locator.count() + if count == 0: + return {"status": "error", "message": f"No elements found for selector: {selector}"} + else: + self.locators[page_index] = locator + return {"status": "ok", "count": count} + + async def locate_by_css(selector: str, page_index: int = 0) -> dict: + """ + Sets the locator for the page at the given index to the result + of a css query on the page with the given css selector. + + Returns the count of elements currently matched by the locator. + """ + locator = self.pages[page_index].locator(f"css={selector}") + count = await locator.count() + if count == 0: + return {"status": "error", "message": f"No elements found for selector: {selector}"} + else: + self.locators[page_index] = locator + return {"status": "ok", "count": count} + + async def locate_by_xpath(selector: str, page_index: int = 0) -> dict: + """ + Sets the locator for the page at the given index to the result + of a xpath query on the page with the given xpath selector. + + Returns the count of elements currently matched by the locator. + """ + locator = self.pages[page_index].locator(f"xpath={selector}") + count = await locator.count() + if count == 0: + return {"status": "error", "message": f"No elements found for selector: {selector}"} + else: + self.locators[page_index] = locator + return {"status": "ok", "count": count} + + async def locate_by_role(role: str, page_index: int = 0) -> dict: + """ + Sets the locator for the page at the given index to the result + of get_by_role with the given role on the current page. + + Returns the count of elements currently matched by the locator. + """ + locator = self.pages[page_index].get_by_role(role) + count = await locator.count() + if count == 0: + return {"status": "error", "message": f"No elements found for role: {role}"} + else: + self.locators[page_index] = locator + return {"status": "ok", "count": await locator.count()} + + async def new_tab() -> dict: + """ + Opens a new tab in the current context. + """ + new_page = await self.context.new_page() + self.pages.append(new_page) + self.locators.append(new_page) + await new_page.bring_to_front() + return {"status": "ok", "page_index": len(self.pages) - 1} + + async def search(query: str, page_index: int = 0) -> dict: + """ + Performs a search for the given query on the page at the given index. + """ + page = self.pages[page_index] + + locators = [ + (page.get_by_label, re.compile(r"search", re.IGNORECASE)), + (page.locator, "input[name='q']"), + (page.locator, "textarea[name='q']"), + (page.locator, "input[type='search']"), + (page.locator, "textarea[type='search']"), + (page.get_by_placeholder, re.compile(r"search", re.IGNORECASE)), + (page.get_by_role, "searchbox"), + ] + + for func, selector in locators: + input_locator = func(selector) + count = await input_locator.count() + if count == 1: + await input_locator.fill(query) + await input_locator.press('Enter') + await page.wait_for_load_state("domcontentloaded") + await asyncio.sleep(1) + return {"status": "ok", "message": "Search submitted"} + + return {"status": "error", "message": "Search input not found"} + + async def snapshot_full(page_index: int = 0) -> dict: + """ + Takes a snapshot of the full page at the given index. + + Returns a list of all elements on the page along with + details about each element. + """ + page = self.pages[page_index] + snapshot = await page.evaluate(""" + () => { + function getRole(el) { + return el.getAttribute('role') || null; + } + + function isVisible(el) { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function isInteractable(el) { + const tag = el.tagName.toLowerCase(); + return ['button', 'a', 'input', 'select', 'textarea', 'label'].includes(tag) || getRole(el); + } + + function getElementInfo(el) { + const rect = el.getBoundingClientRect(); + return { + tag: el.tagName.toLowerCase(), + role: getRole(el), + text: el.innerText || el.value || '', + name: el.getAttribute('name') || '', + type: el.getAttribute('type') || '', + ariaLabel: el.getAttribute('aria-label') || '', + id: el.id || '', + class: el.className || '', + boundingBox: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + } + }; + } + + const elements = Array.from(document.querySelectorAll('*')); + return elements + .filter(el => isVisible(el) && isInteractable(el)) + .map(el => getElementInfo(el)); + } + """) + return {"status": "ok", "snapshot": snapshot} + + async def snapshot_main(page_index: int = 0) -> dict: + """ + Takes a snapshot of the main content of the page + at the given index. + + Returns a list of all elements under the 'main' element + on the page along with details about each element, or + and empty list if no 'main' element is found. + """ + page = self.pages[page_index] + snapshot = await page.evaluate(""" + () => { + function getRole(el) { + return el.getAttribute('role') || null; + } + + function isVisible(el) { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function isInteractable(el) { + const tag = el.tagName.toLowerCase(); + return ['button', 'a', 'input', 'select', 'textarea', 'label'].includes(tag) || getRole(el); + } + + function getElementInfo(el) { + const rect = el.getBoundingClientRect(); + return { + tag: el.tagName.toLowerCase(), + role: getRole(el), + text: el.innerText || el.value || '', + name: el.getAttribute('name') || '', + type: el.getAttribute('type') || '', + ariaLabel: el.getAttribute('aria-label') || '', + id: el.id || '', + class: el.className || '', + boundingBox: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + } + }; + } + + let main = document.querySelector("main, #main, [role=main]") + if (!main) return []; + + const elements = Array.from(main.querySelectorAll('*')); + return elements + .filter(el => isVisible(el) && isInteractable(el)) + .map(el => getElementInfo(el)); + } + """) + if not snapshot: + return {"status": "error", "message": "No main content found"} + else: + return {"status": "ok", "snapshot": snapshot} + + async def text_contents(page_index: int = 0) -> dict: + """ + Gets an array of the text contents of the elements + matched by the locator for the page at the given index. + """ + texts = await self.locators[page_index].all_text_contents() + return {"status": "ok", "texts": texts} + + async def title(limit: int, page_index: int = 0) -> dict: + """ + Get the title of the current page. + """ + title = await self.pages[page_index].title() + return {"status": "ok", "title": title} + + return [ + Tool(bring_to_front), + Tool(click), + Tool(click_nth), + Tool(current_iso8601_utc_time), + Tool(enter), + Tool(fill), + Tool(fill_active), + Tool(filter), + Tool(goto), + Tool(locate), + Tool(locate_by_css), + Tool(locate_by_xpath), + Tool(locate_by_role), + Tool(new_tab), + Tool(search), + Tool(snapshot_full), + Tool(snapshot_main), + Tool(text_contents), + Tool(title) + ] + + @staticmethod + async def start(browser): + context = await browser.new_context() + return BrowserSession(browser, context) + + +async def main(node): + browser = await Browser.start("ws://localhost:3000/browser") + browser_session = await BrowserSession.start(browser) + tools = browser_session.tools() + + await Agent.start( + node=node, + name="jack", + model=Model("nova-pro-v1"), + tools=tools, + instructions=""" + You are a web browsing agent. + You are given a set of tools to drive a web browser and + to interact with web pages. + + Your primary means of getting information from a page is + through the snapshotss. When finding information on a page + prefer snapshot_main and then snapshow_full over locators. However + because snapshots are long, do not put snapshots directly in a response. + + For any search request, attempt fill_active then enter before resorting + to locators, fill, and clicks. + + For any request you should first break the request down into a series + of steps that you can accomplish with the tools you have available. + """ + ) + + +Node.start(main, llm_debug=True) diff --git a/examples/013/images/main/requirements.txt b/examples/013/images/main/requirements.txt new file mode 100644 index 00000000000..fa4bf8fc0c8 --- /dev/null +++ b/examples/013/images/main/requirements.txt @@ -0,0 +1 @@ +playwright==1.52.0 \ No newline at end of file diff --git a/examples/013/node_server/package-lock.json b/examples/013/node_server/package-lock.json new file mode 100644 index 00000000000..a73731cf669 --- /dev/null +++ b/examples/013/node_server/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "headed-playwright-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "headed-playwright-server", + "version": "1.0.0", + "dependencies": { + "playwright": "1.52.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/examples/013/node_server/package.json b/examples/013/node_server/package.json new file mode 100644 index 00000000000..2460d399943 --- /dev/null +++ b/examples/013/node_server/package.json @@ -0,0 +1,13 @@ +{ + "name": "headed-playwright-server", + "version": "1.0.0", + "type": "module", + "description": "Local Playwright WebSocket server with headed Chromium", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "playwright": "1.52.0" + } +} \ No newline at end of file diff --git a/examples/013/node_server/server.js b/examples/013/node_server/server.js new file mode 100644 index 00000000000..e65d668d234 --- /dev/null +++ b/examples/013/node_server/server.js @@ -0,0 +1,24 @@ +import { chromium } from 'playwright'; +import { URL } from 'url'; + +(async () => { + const browserServer = await chromium.launchServer({ + headless: false, + wsPath: 'browser', + }); + + const wsEndpoint = browserServer.wsEndpoint(); + const url = new URL(wsEndpoint); + console.log(`ockam zone outlet --relay browser --to localhost:${url.port}`); + console.log(`WebSocket endpoint: ${wsEndpoint}`); + + console.log("Press Ctrl+C to exit."); + process.stdin.resume(); + + process.on('SIGINT', () => { + console.log('Ctrl+C detected. Exiting.'); + process.exit(0); + }); + + +})(); \ No newline at end of file diff --git a/examples/013/ockam.yaml b/examples/013/ockam.yaml new file mode 100644 index 00000000000..3ce89e75dc9 --- /dev/null +++ b/examples/013/ockam.yaml @@ -0,0 +1,12 @@ +name: example013 +pods: + - name: main-pod + size: big + containers: + - name: main + image: main + + portals: + inlets: + - from: 3000 + name: browser