omniai-tools 0.5.0 → 0.5.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.
- checksums.yaml +4 -4
- data/README.md +3 -12
- data/lib/omniai/tools/browser/base_driver.rb +78 -0
- data/lib/omniai/tools/browser/base_tool.rb +31 -4
- data/lib/omniai/tools/browser/button_click_tool.rb +1 -14
- data/lib/omniai/tools/browser/element_click_tool.rb +30 -0
- data/lib/omniai/tools/browser/elements/element_grouper.rb +73 -0
- data/lib/omniai/tools/browser/elements/nearby_element_detector.rb +108 -0
- data/lib/omniai/tools/browser/formatters/action_formatter.rb +37 -0
- data/lib/omniai/tools/browser/formatters/data_entry_formatter.rb +135 -0
- data/lib/omniai/tools/browser/formatters/element_formatter.rb +52 -0
- data/lib/omniai/tools/browser/formatters/input_formatter.rb +59 -0
- data/lib/omniai/tools/browser/inspect_tool.rb +46 -13
- data/lib/omniai/tools/browser/inspect_utils.rb +51 -0
- data/lib/omniai/tools/browser/link_click_tool.rb +2 -14
- data/lib/omniai/tools/browser/page_inspect/button_summarizer.rb +140 -0
- data/lib/omniai/tools/browser/page_inspect/form_summarizer.rb +98 -0
- data/lib/omniai/tools/browser/page_inspect/html_summarizer.rb +37 -0
- data/lib/omniai/tools/browser/page_inspect/link_summarizer.rb +103 -0
- data/lib/omniai/tools/browser/page_inspect_tool.rb +30 -0
- data/lib/omniai/tools/browser/page_screenshot_tool.rb +22 -0
- data/lib/omniai/tools/browser/selector_generator/base_selectors.rb +28 -0
- data/lib/omniai/tools/browser/selector_generator/contextual_selectors.rb +140 -0
- data/lib/omniai/tools/browser/selector_generator.rb +73 -0
- data/lib/omniai/tools/browser/selector_inspect_tool.rb +44 -0
- data/lib/omniai/tools/browser/text_field_area_set_tool.rb +2 -31
- data/lib/omniai/tools/browser/visit_tool.rb +1 -1
- data/lib/omniai/tools/browser/watir_driver.rb +224 -0
- data/lib/omniai/tools/browser_tool.rb +265 -0
- data/lib/omniai/tools/version.rb +1 -1
- metadata +23 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: cc814f27a50bb0ecceaff25eb4153aed6ade80ca24f7abcb74198e0a31afe627
         | 
| 4 | 
            +
              data.tar.gz: c68820e0b080dc4812cd15871c8da74963d27b20960dd9e9e254b214bb313dfa
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 6ad4b2eb6e1cee4bbfd6b34f1957bb663de22785f4b2071de5323d38d5614c9438f813bbab9b98b9a232335a43243e82191103e59aca054400323f6137f5886e
         | 
| 7 | 
            +
              data.tar.gz: faf87bbacaf66022c34beb835674deda75b359e937bc4a734918b8f2cc28985438eba994b40ac0553c8fb1ab7bfa433532cd4cbe0d6dffa3fa5298fa904565dd
         | 
    
        data/README.md
    CHANGED
    
    | @@ -16,20 +16,11 @@ Database tools are focused on running SQL statements: | |
| 16 16 | 
             
            require "omniai/openai"
         | 
| 17 17 | 
             
            require "omniai/tools"
         | 
| 18 18 |  | 
| 19 | 
            -
            require "watir"
         | 
| 20 | 
            -
             | 
| 21 | 
            -
            browser = Watir::Browser.new(:chrome)
         | 
| 22 | 
            -
             | 
| 23 19 | 
             
            client = OmniAI::OpenAI::Client.new
         | 
| 24 | 
            -
            logger = Logger.new($stdout)
         | 
| 25 20 |  | 
| 26 | 
            -
             | 
| 27 | 
            -
             | 
| 28 | 
            -
             | 
| 29 | 
            -
              OmniAI::Tools::Browser::ButtonClickTool,
         | 
| 30 | 
            -
              OmniAI::Tools::Browser::LinkClickTool,
         | 
| 31 | 
            -
              OmniAI::Tools::Browser::TextFieldAreaSetTool,
         | 
| 32 | 
            -
            ].map { |klass| klass.new(browser:, logger:) }
         | 
| 21 | 
            +
            logger = Logger.new($stdout)
         | 
| 22 | 
            +
            driver = OmniAI::Tools::Browser::WatirDriver.new
         | 
| 23 | 
            +
            tools = [OmniAI::Tools::BrowserTool.new(driver: logger:)]
         | 
| 33 24 |  | 
| 34 25 | 
             
            puts "Type 'exit' or 'quit' to leave."
         | 
| 35 26 |  | 
| @@ -0,0 +1,78 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module OmniAI
         | 
| 4 | 
            +
              module Tools
         | 
| 5 | 
            +
                module Browser
         | 
| 6 | 
            +
                  # A base driver intended to be overridden by specific browser drivers (e.g. waitir).
         | 
| 7 | 
            +
                  class BaseDriver
         | 
| 8 | 
            +
                    TIMEOUT = Integer(ENV.fetch("OMNIAI_BROWSER_TIMEOUT", 10))
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                    # @param logger [Logger]
         | 
| 11 | 
            +
                    def initialize(logger: Logger.new(IO::NULL))
         | 
| 12 | 
            +
                      @logger = logger
         | 
| 13 | 
            +
                    end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                    def close
         | 
| 16 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 17 | 
            +
                    end
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                    # @return [String]
         | 
| 20 | 
            +
                    def url
         | 
| 21 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 22 | 
            +
                    end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                    # @return [String]
         | 
| 25 | 
            +
                    def title
         | 
| 26 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 27 | 
            +
                    end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                    # @return [String]
         | 
| 30 | 
            +
                    def html
         | 
| 31 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 32 | 
            +
                    end
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                    # @yield [file]
         | 
| 35 | 
            +
                    # @yieldparam file [File]
         | 
| 36 | 
            +
                    def screenshot
         | 
| 37 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 38 | 
            +
                    end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                    # @param url [String]
         | 
| 41 | 
            +
                    #
         | 
| 42 | 
            +
                    # @return [Hash]
         | 
| 43 | 
            +
                    def goto(url:)
         | 
| 44 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 45 | 
            +
                    end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                    # @param selector [String]
         | 
| 48 | 
            +
                    # @param text [String]
         | 
| 49 | 
            +
                    #
         | 
| 50 | 
            +
                    # @return [Hash]
         | 
| 51 | 
            +
                    def fill_in(selector:, text:)
         | 
| 52 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 53 | 
            +
                    end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                    # @param selector [String]
         | 
| 56 | 
            +
                    #
         | 
| 57 | 
            +
                    # @return [Hash]
         | 
| 58 | 
            +
                    def button_click(selector:)
         | 
| 59 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 60 | 
            +
                    end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                    # @param selector [String]
         | 
| 63 | 
            +
                    #
         | 
| 64 | 
            +
                    # @return [Hash]
         | 
| 65 | 
            +
                    def link_click(selector:)
         | 
| 66 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 67 | 
            +
                    end
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                    # @param selector [String]
         | 
| 70 | 
            +
                    #
         | 
| 71 | 
            +
                    # @return [Hash]
         | 
| 72 | 
            +
                    def element_click(selector:)
         | 
| 73 | 
            +
                      raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
         | 
| 74 | 
            +
                    end
         | 
| 75 | 
            +
                  end
         | 
| 76 | 
            +
                end
         | 
| 77 | 
            +
              end
         | 
| 78 | 
            +
            end
         | 
| @@ -10,12 +10,39 @@ module OmniAI | |
| 10 10 | 
             
                  #     # ...
         | 
| 11 11 | 
             
                  #   end
         | 
| 12 12 | 
             
                  class BaseTool < OmniAI::Tool
         | 
| 13 | 
            -
                    # @param logger [ | 
| 14 | 
            -
                    # @param  | 
| 15 | 
            -
                    def initialize( | 
| 13 | 
            +
                    # @param logger [Logger]
         | 
| 14 | 
            +
                    # @param driver [BaseDriver]
         | 
| 15 | 
            +
                    def initialize(driver:, logger: Logger.new(IO::NULL))
         | 
| 16 16 | 
             
                      super()
         | 
| 17 | 
            +
                      @driver = driver
         | 
| 17 18 | 
             
                      @logger = logger
         | 
| 18 | 
            -
             | 
| 19 | 
            +
                    end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  protected
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                    def wait_for_element
         | 
| 24 | 
            +
                      return yield if defined?(RSpec) # Skip waiting in tests
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                      Watir::Wait.until(timeout: 10) do
         | 
| 27 | 
            +
                        element = yield
         | 
| 28 | 
            +
                        element if element && element_visible?(element)
         | 
| 29 | 
            +
                      end
         | 
| 30 | 
            +
                    rescue Watir::Wait::TimeoutError
         | 
| 31 | 
            +
                      log_element_timeout
         | 
| 32 | 
            +
                      nil
         | 
| 33 | 
            +
                    end
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                    def element_visible?(element)
         | 
| 36 | 
            +
                      return true unless element.respond_to?(:visible?)
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                      element.visible?
         | 
| 39 | 
            +
                    end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                    def log_element_timeout
         | 
| 42 | 
            +
                      return unless @browser.respond_to?(:elements)
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                      visible_elements = @browser.elements.select(&:visible?).map(&:text).compact.first(10)
         | 
| 45 | 
            +
                      @logger.error("Element not found after 10s. Sample visible elements: #{visible_elements}")
         | 
| 19 46 | 
             
                    end
         | 
| 20 47 | 
             
                  end
         | 
| 21 48 | 
             
                end
         | 
| @@ -16,23 +16,10 @@ module OmniAI | |
| 16 16 |  | 
| 17 17 | 
             
                    required %i[selector]
         | 
| 18 18 |  | 
| 19 | 
            -
                    # @param to [String] The URL to navigate to.
         | 
| 20 19 | 
             
                    def execute(selector:)
         | 
| 21 20 | 
             
                      @logger.info("#{self.class.name}##{__method__} selector=#{selector.inspect}")
         | 
| 22 21 |  | 
| 23 | 
            -
                       | 
| 24 | 
            -
             | 
| 25 | 
            -
                      return { error: "unknown selector=#{selector}" } if element.nil?
         | 
| 26 | 
            -
             | 
| 27 | 
            -
                      element.click
         | 
| 28 | 
            -
                    end
         | 
| 29 | 
            -
             | 
| 30 | 
            -
                  protected
         | 
| 31 | 
            -
             | 
| 32 | 
            -
                    # @return [Watir::Anchor, nil]
         | 
| 33 | 
            -
                    def find(selector)
         | 
| 34 | 
            -
                      element = @browser.button(selector)
         | 
| 35 | 
            -
                      element if element.exists?
         | 
| 22 | 
            +
                      @driver.button_click(selector:)
         | 
| 36 23 | 
             
                    end
         | 
| 37 24 | 
             
                  end
         | 
| 38 25 | 
             
                end
         | 
| @@ -0,0 +1,30 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module OmniAI
         | 
| 4 | 
            +
              module Tools
         | 
| 5 | 
            +
                module Browser
         | 
| 6 | 
            +
                  # @example
         | 
| 7 | 
            +
                  #   browser = Watir::Browser.new(:chrome)
         | 
| 8 | 
            +
                  #   tool = OmniAI::Tools::Browser::ElementClickTool.new(browser:)
         | 
| 9 | 
            +
                  #   tool.execute(selector: "#some-id")
         | 
| 10 | 
            +
                  #   tool.execute(selector: ".some-class")
         | 
| 11 | 
            +
                  #   tool.execute(selector: "some text")
         | 
| 12 | 
            +
                  #   tool.execute(selector: "//div[@role='button']")
         | 
| 13 | 
            +
                  class ElementClickTool < BaseTool
         | 
| 14 | 
            +
                    description "A browser automation tool for clicking any clickable element."
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                    parameter :selector, :string,
         | 
| 17 | 
            +
                      description: "CSS selector, ID, text content, or other identifier for the element to click."
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                    required %i[selector]
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                    # @param selector [String] CSS selector, ID, text content, or other identifier for the element to click.
         | 
| 22 | 
            +
                    def execute(selector:)
         | 
| 23 | 
            +
                      @logger.info("#{self.class.name}##{__method__} selector=#{selector.inspect}")
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                      @driver.element_click(selector:)
         | 
| 26 | 
            +
                    end
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
                end
         | 
| 29 | 
            +
              end
         | 
| 30 | 
            +
            end
         | 
| @@ -0,0 +1,73 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module OmniAI
         | 
| 4 | 
            +
              module Tools
         | 
| 5 | 
            +
                module Browser
         | 
| 6 | 
            +
                  module Elements
         | 
| 7 | 
            +
                    # Groups HTML elements by their relevance for data entry
         | 
| 8 | 
            +
                    module ElementGrouper
         | 
| 9 | 
            +
                    module_function
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                      def group_for_data_entry(elements, text)
         | 
| 12 | 
            +
                        groups = {
         | 
| 13 | 
            +
                          inputs: [], # text, number, email inputs - primary targets
         | 
| 14 | 
            +
                          form_controls: {}, # radio buttons, checkboxes grouped by name
         | 
| 15 | 
            +
                          labels: [], # labels, headers, spans
         | 
| 16 | 
            +
                          actions: [], # buttons and actionable elements
         | 
| 17 | 
            +
                        }
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                        elements.each do |element|
         | 
| 20 | 
            +
                          categorize_element(element, groups, text)
         | 
| 21 | 
            +
                        end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                        groups
         | 
| 24 | 
            +
                      end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                      def categorize_element(element, groups, text)
         | 
| 27 | 
            +
                        case element.name.downcase
         | 
| 28 | 
            +
                        when "input"
         | 
| 29 | 
            +
                          categorize_input_element(element, groups)
         | 
| 30 | 
            +
                        when "textarea", "select"
         | 
| 31 | 
            +
                          groups[:inputs] << element
         | 
| 32 | 
            +
                        when "button"
         | 
| 33 | 
            +
                          groups[:actions] << element if contains_text_match?(element, text)
         | 
| 34 | 
            +
                        when "label", "span", "div", "th"
         | 
| 35 | 
            +
                          groups[:labels] << element if contains_text_match?(element, text)
         | 
| 36 | 
            +
                        end
         | 
| 37 | 
            +
                      end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                      def categorize_input_element(element, groups)
         | 
| 40 | 
            +
                        if data_entry_input?(element)
         | 
| 41 | 
            +
                          groups[:inputs] << element
         | 
| 42 | 
            +
                        else
         | 
| 43 | 
            +
                          group_form_control(element, groups[:form_controls])
         | 
| 44 | 
            +
                        end
         | 
| 45 | 
            +
                      end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                      def data_entry_input?(element)
         | 
| 48 | 
            +
                        # Handle missing type attribute - HTML default is "text"
         | 
| 49 | 
            +
                        type = (element["type"] || "text").downcase
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                        # Include common data entry input types
         | 
| 52 | 
            +
                        %w[text number email tel url date datetime-local time month week
         | 
| 53 | 
            +
                           password search].include?(type)
         | 
| 54 | 
            +
                      end
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                      def group_form_control(element, form_controls)
         | 
| 57 | 
            +
                        type = (element["type"] || "text").downcase
         | 
| 58 | 
            +
                        return unless %w[radio checkbox].include?(type)
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                        name = element["name"] || "unnamed"
         | 
| 61 | 
            +
                        form_controls[name] ||= []
         | 
| 62 | 
            +
                        form_controls[name] << element
         | 
| 63 | 
            +
                      end
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                      def contains_text_match?(element, text)
         | 
| 66 | 
            +
                        element.text.downcase.include?(text.downcase) ||
         | 
| 67 | 
            +
                          element["value"]&.downcase&.include?(text.downcase)
         | 
| 68 | 
            +
                      end
         | 
| 69 | 
            +
                    end
         | 
| 70 | 
            +
                  end
         | 
| 71 | 
            +
                end
         | 
| 72 | 
            +
              end
         | 
| 73 | 
            +
            end
         | 
| @@ -0,0 +1,108 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module OmniAI
         | 
| 4 | 
            +
              module Tools
         | 
| 5 | 
            +
                module Browser
         | 
| 6 | 
            +
                  module Elements
         | 
| 7 | 
            +
                    # Handles detection of interactive elements near text matches
         | 
| 8 | 
            +
                    module NearbyElementDetector
         | 
| 9 | 
            +
                    module_function
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                      def add_nearby_interactive_elements(elements)
         | 
| 12 | 
            +
                        nearby_elements = []
         | 
| 13 | 
            +
                        elements.each do |element|
         | 
| 14 | 
            +
                          nearby_elements += find_interactive_in_context(element)
         | 
| 15 | 
            +
                        end
         | 
| 16 | 
            +
                        combined = elements.to_a + nearby_elements
         | 
| 17 | 
            +
                        combined.uniq
         | 
| 18 | 
            +
                      end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                      def find_interactive_in_context(element)
         | 
| 21 | 
            +
                        interactive_elements = []
         | 
| 22 | 
            +
                        interactive_elements += check_table_context(element)
         | 
| 23 | 
            +
                        interactive_elements += check_parent_containers(element)
         | 
| 24 | 
            +
                        interactive_elements.uniq
         | 
| 25 | 
            +
                      end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                      def check_table_context(element)
         | 
| 28 | 
            +
                        elements = []
         | 
| 29 | 
            +
                        table_cell = element.ancestors("td").first || element.ancestors("th").first
         | 
| 30 | 
            +
                        elements += find_interactive_in_container(table_cell) if table_cell
         | 
| 31 | 
            +
                        table_row = element.ancestors("tr").first
         | 
| 32 | 
            +
                        elements += find_interactive_in_container(table_row) if table_row
         | 
| 33 | 
            +
                        if table_cell && table_row
         | 
| 34 | 
            +
                          table = element.ancestors("table").first
         | 
| 35 | 
            +
                          elements += find_interactive_in_same_column(table, table_cell, table_row) if table
         | 
| 36 | 
            +
                        end
         | 
| 37 | 
            +
                        elements
         | 
| 38 | 
            +
                      end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                      def find_interactive_in_same_column(table, header_cell, header_row)
         | 
| 41 | 
            +
                        return [] unless table
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                        column_index = header_row.css("th, td").index(header_cell)
         | 
| 44 | 
            +
                        return [] unless column_index
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                        interactive_elements = []
         | 
| 47 | 
            +
                        table.css("tbody tr").each do |row|
         | 
| 48 | 
            +
                          interactive_elements += find_column_cell_elements(row, column_index)
         | 
| 49 | 
            +
                        end
         | 
| 50 | 
            +
                        interactive_elements
         | 
| 51 | 
            +
                      end
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                      def find_column_cell_elements(row, column_index)
         | 
| 54 | 
            +
                        target_cell = row.css("td, th")[column_index]
         | 
| 55 | 
            +
                        return [] unless target_cell
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                        find_interactive_in_container(target_cell) + find_interactive_in_nested_tables(target_cell, column_index)
         | 
| 58 | 
            +
                      end
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                      def find_interactive_in_nested_tables(cell, original_column_index)
         | 
| 61 | 
            +
                        interactive_elements = []
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                        cell.css("table").each do |nested_table|
         | 
| 64 | 
            +
                          nested_table.css("tr").each do |nested_row|
         | 
| 65 | 
            +
                            nested_cells = nested_row.css("td, th")
         | 
| 66 | 
            +
                            if nested_cells[original_column_index]
         | 
| 67 | 
            +
                              interactive_elements += find_interactive_in_container(nested_cells[original_column_index])
         | 
| 68 | 
            +
                            end
         | 
| 69 | 
            +
                          end
         | 
| 70 | 
            +
                        end
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                        interactive_elements
         | 
| 73 | 
            +
                      end
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                      def check_parent_containers(element)
         | 
| 76 | 
            +
                        interactive_elements = []
         | 
| 77 | 
            +
                        parent_container = element.parent
         | 
| 78 | 
            +
                        3.times do
         | 
| 79 | 
            +
                          break unless parent_container
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                          interactive_elements += find_interactive_in_container(parent_container)
         | 
| 82 | 
            +
                          parent_container = parent_container.parent
         | 
| 83 | 
            +
                        end
         | 
| 84 | 
            +
                        interactive_elements
         | 
| 85 | 
            +
                      end
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                      def find_interactive_in_container(container)
         | 
| 88 | 
            +
                        return [] unless container
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                        container.css("input, textarea, select, button, [role='button'], [tabindex='0']")
         | 
| 91 | 
            +
                          .reject { |el| non_interactive_element?(el) }
         | 
| 92 | 
            +
                      end
         | 
| 93 | 
            +
             | 
| 94 | 
            +
                      def non_interactive_element?(element)
         | 
| 95 | 
            +
                        return true if element["type"] == "hidden"
         | 
| 96 | 
            +
                        return true if element["tabindex"] == "-1"
         | 
| 97 | 
            +
                        return true if element["aria-hidden"] == "true"
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                        style = element["style"]
         | 
| 100 | 
            +
                        return true if style&.include?("display: none") || style&.include?("visibility: hidden")
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                        false
         | 
| 103 | 
            +
                      end
         | 
| 104 | 
            +
                    end
         | 
| 105 | 
            +
                  end
         | 
| 106 | 
            +
                end
         | 
| 107 | 
            +
              end
         | 
| 108 | 
            +
            end
         | 
| @@ -0,0 +1,37 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module OmniAI
         | 
| 4 | 
            +
              module Tools
         | 
| 5 | 
            +
                module Browser
         | 
| 6 | 
            +
                  module Formatters
         | 
| 7 | 
            +
                    # Handles formatting of action elements (buttons, links)
         | 
| 8 | 
            +
                    module ActionFormatter
         | 
| 9 | 
            +
                    module_function
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                      def format_actions(actions)
         | 
| 12 | 
            +
                        result = "⚡ Available Actions:\n"
         | 
| 13 | 
            +
                        actions.first(5).each do |action|
         | 
| 14 | 
            +
                          result += format_action_element(action)
         | 
| 15 | 
            +
                        end
         | 
| 16 | 
            +
                        result += "  ... and #{actions.size - 5} more\n" if actions.size > 5
         | 
| 17 | 
            +
                        result += "\n"
         | 
| 18 | 
            +
                      end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                      def format_action_element(action)
         | 
| 21 | 
            +
                        text = action.text.strip
         | 
| 22 | 
            +
                        selector = get_action_selector(action)
         | 
| 23 | 
            +
                        "  • #{text} (#{selector})\n"
         | 
| 24 | 
            +
                      end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                      def get_action_selector(action)
         | 
| 27 | 
            +
                        return action["id"] if action["id"] && !action["id"].empty?
         | 
| 28 | 
            +
                        return "text:#{action.text.strip}" if action.text.strip.length > 2
         | 
| 29 | 
            +
                        return action["class"].split.first if action["class"]
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                        "css-needed"
         | 
| 32 | 
            +
                      end
         | 
| 33 | 
            +
                    end
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
              end
         | 
| 37 | 
            +
            end
         | 
| @@ -0,0 +1,135 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module OmniAI
         | 
| 4 | 
            +
              module Tools
         | 
| 5 | 
            +
                module Browser
         | 
| 6 | 
            +
                  module Formatters
         | 
| 7 | 
            +
                    # Formats grouped elements for optimal data entry display
         | 
| 8 | 
            +
                    module DataEntryFormatter
         | 
| 9 | 
            +
                    module_function
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                      def format_groups(grouped_elements, total_count, text)
         | 
| 12 | 
            +
                        result = build_header(total_count, text)
         | 
| 13 | 
            +
                        result += format_each_group(grouped_elements)
         | 
| 14 | 
            +
                        result
         | 
| 15 | 
            +
                      end
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                      def format_inputs(inputs)
         | 
| 18 | 
            +
                        result = "📝 Data Entry Fields:\n"
         | 
| 19 | 
            +
                        table_groups = group_by_table_context(inputs)
         | 
| 20 | 
            +
                        result += format_table_groups(table_groups)
         | 
| 21 | 
            +
                        "#{result}\n"
         | 
| 22 | 
            +
                      end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                      def format_form_controls(form_controls)
         | 
| 25 | 
            +
                        result = "🎛️ Form Controls:\n"
         | 
| 26 | 
            +
                        form_controls.each do |name, controls|
         | 
| 27 | 
            +
                          result += format_control_group(name, controls)
         | 
| 28 | 
            +
                        end
         | 
| 29 | 
            +
                        result += "\n"
         | 
| 30 | 
            +
                      end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                      def format_labels(labels)
         | 
| 33 | 
            +
                        result = "🏷️ Labels & Headers:\n"
         | 
| 34 | 
            +
                        labels.first(3).each do |label|
         | 
| 35 | 
            +
                          result += "  • #{label.name}: #{label.text.strip}\n"
         | 
| 36 | 
            +
                        end
         | 
| 37 | 
            +
                        result += "  ... and #{labels.size - 3} more\n" if labels.size > 3
         | 
| 38 | 
            +
                        result += "\n"
         | 
| 39 | 
            +
                      end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                      def build_header(total_count, text)
         | 
| 42 | 
            +
                        "Found #{total_count} elements containing '#{text}':\n\n"
         | 
| 43 | 
            +
                      end
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                      def format_each_group(grouped_elements)
         | 
| 46 | 
            +
                        [
         | 
| 47 | 
            +
                          format_inputs_section(grouped_elements[:inputs]),
         | 
| 48 | 
            +
                          format_actions_section(grouped_elements[:actions]),
         | 
| 49 | 
            +
                          format_controls_section(grouped_elements[:form_controls]),
         | 
| 50 | 
            +
                          format_labels_section(grouped_elements[:labels]),
         | 
| 51 | 
            +
                        ].join
         | 
| 52 | 
            +
                      end
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                      def format_inputs_section(inputs)
         | 
| 55 | 
            +
                        inputs.any? ? format_inputs(inputs) : ""
         | 
| 56 | 
            +
                      end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                      def format_actions_section(actions)
         | 
| 59 | 
            +
                        actions.any? ? ActionFormatter.format_actions(actions) : ""
         | 
| 60 | 
            +
                      end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                      def format_controls_section(form_controls)
         | 
| 63 | 
            +
                        form_controls.any? ? format_form_controls(form_controls) : ""
         | 
| 64 | 
            +
                      end
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                      def format_labels_section(labels)
         | 
| 67 | 
            +
                        labels.any? ? format_labels(labels) : ""
         | 
| 68 | 
            +
                      end
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                      def format_control_group(name, controls)
         | 
| 71 | 
            +
                        result = "\n  #{name.humanize} options:\n"
         | 
| 72 | 
            +
                        controls.each do |control|
         | 
| 73 | 
            +
                          checked = control["checked"] ? " \u2713" : ""
         | 
| 74 | 
            +
                          result += "    • #{control['value']}#{checked}\n"
         | 
| 75 | 
            +
                        end
         | 
| 76 | 
            +
                        result
         | 
| 77 | 
            +
                      end
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                      def format_table_groups(table_groups)
         | 
| 80 | 
            +
                        return format_single_group(table_groups.values.first) if table_groups.size == 1
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                        result = ""
         | 
| 83 | 
            +
                        table_groups.each do |(context, group_inputs)|
         | 
| 84 | 
            +
                          result += "\n  #{context}:\n"
         | 
| 85 | 
            +
                          group_inputs.each { |input| result += InputFormatter.format_input_field(input, "    ") }
         | 
| 86 | 
            +
                        end
         | 
| 87 | 
            +
                        result
         | 
| 88 | 
            +
                      end
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                      def format_single_group(inputs)
         | 
| 91 | 
            +
                        result = ""
         | 
| 92 | 
            +
                        inputs.each { |input| result += InputFormatter.format_input_field(input, "  ") }
         | 
| 93 | 
            +
                        result
         | 
| 94 | 
            +
                      end
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                      def group_by_table_context(inputs)
         | 
| 97 | 
            +
                        groups = {}
         | 
| 98 | 
            +
                        inputs.each do |input|
         | 
| 99 | 
            +
                          context = find_context(input)
         | 
| 100 | 
            +
                          groups[context] ||= []
         | 
| 101 | 
            +
                          groups[context] << input
         | 
| 102 | 
            +
                        end
         | 
| 103 | 
            +
                        groups
         | 
| 104 | 
            +
                      end
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                      def find_context(input)
         | 
| 107 | 
            +
                        table = input.ancestors("table").first
         | 
| 108 | 
            +
                        return "Form Fields" unless table
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                        find_table_context(input)
         | 
| 111 | 
            +
                      end
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                      def find_table_context(input)
         | 
| 114 | 
            +
                        row = input.ancestors("tr").first
         | 
| 115 | 
            +
                        return "Table" unless row
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                        find_meaningful_cell_text(row)
         | 
| 118 | 
            +
                      end
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                      def find_meaningful_cell_text(row)
         | 
| 121 | 
            +
                        meaningful_cell = row.css("td, th").find do |cell|
         | 
| 122 | 
            +
                          cell.text.strip.length > 2 && !cell.text.match?(/^\d+\.?\d*$/)
         | 
| 123 | 
            +
                        end
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                        meaningful_cell ? meaningful_cell.text.strip[0..30] : "Table"
         | 
| 126 | 
            +
                      end
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                      def humanize(text)
         | 
| 129 | 
            +
                        text.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
         | 
| 130 | 
            +
                      end
         | 
| 131 | 
            +
                    end
         | 
| 132 | 
            +
                  end
         | 
| 133 | 
            +
                end
         | 
| 134 | 
            +
              end
         | 
| 135 | 
            +
            end
         | 
| @@ -0,0 +1,52 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module OmniAI
         | 
| 4 | 
            +
              module Tools
         | 
| 5 | 
            +
                module Browser
         | 
| 6 | 
            +
                  module Formatters
         | 
| 7 | 
            +
                    # Handles formatting of HTML elements for display with data entry focus
         | 
| 8 | 
            +
                    module ElementFormatter
         | 
| 9 | 
            +
                    module_function
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                      def format_matching_elements(elements, text, _context_size = nil)
         | 
| 12 | 
            +
                        grouped = Elements::ElementGrouper.group_for_data_entry(elements, text)
         | 
| 13 | 
            +
                        DataEntryFormatter.format_groups(grouped, elements.size, text)
         | 
| 14 | 
            +
                      end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                      # Keep existing methods for backward compatibility
         | 
| 17 | 
            +
                      def format_single_element(element, index, context_size)
         | 
| 18 | 
            +
                        result = "--- Element #{index + 1} ---\n"
         | 
| 19 | 
            +
                        result += "Tag: #{element.name}\n"
         | 
| 20 | 
            +
                        result += format_element_attributes(element)
         | 
| 21 | 
            +
                        result += get_parent_context(element, context_size) if context_size.positive?
         | 
| 22 | 
            +
                        result += "HTML: #{element.to_html}\n\n"
         | 
| 23 | 
            +
                        result
         | 
| 24 | 
            +
                      end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                      def format_element_attributes(element)
         | 
| 27 | 
            +
                        result = ""
         | 
| 28 | 
            +
                        result += "ID: #{element['id']}\n" if element["id"]
         | 
| 29 | 
            +
                        result += "Classes: #{element['class']}\n" if element["class"]
         | 
| 30 | 
            +
                        %w[href src alt type value placeholder].each do |attr|
         | 
| 31 | 
            +
                          result += "#{attr}: #{element[attr]}\n" if element[attr] && !element[attr].empty?
         | 
| 32 | 
            +
                        end
         | 
| 33 | 
            +
                        result
         | 
| 34 | 
            +
                      end
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                      def get_parent_context(element, context_size)
         | 
| 37 | 
            +
                        result = ""
         | 
| 38 | 
            +
                        parent = element.parent
         | 
| 39 | 
            +
                        context_count = 0
         | 
| 40 | 
            +
                        while parent && context_count < context_size
         | 
| 41 | 
            +
                          attrs = parent.attributes.map { |name, attr| " #{name}=\"#{attr.value}\"" }.join
         | 
| 42 | 
            +
                          result += "Parent #{context_count + 1}: <#{parent.name}#{attrs}>\n"
         | 
| 43 | 
            +
                          parent = parent.parent
         | 
| 44 | 
            +
                          context_count += 1
         | 
| 45 | 
            +
                        end
         | 
| 46 | 
            +
                        result
         | 
| 47 | 
            +
                      end
         | 
| 48 | 
            +
                    end
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
                end
         | 
| 51 | 
            +
              end
         | 
| 52 | 
            +
            end
         | 
| @@ -0,0 +1,59 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module OmniAI
         | 
| 4 | 
            +
              module Tools
         | 
| 5 | 
            +
                module Browser
         | 
| 6 | 
            +
                  module Formatters
         | 
| 7 | 
            +
                    # Handles formatting of input elements
         | 
| 8 | 
            +
                    module InputFormatter
         | 
| 9 | 
            +
                    module_function
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                      def format_input_field(input, indent = "")
         | 
| 12 | 
            +
                        result = format_basic_line(input, indent)
         | 
| 13 | 
            +
                        result += format_selectors_line(input, indent)
         | 
| 14 | 
            +
                        result
         | 
| 15 | 
            +
                      end
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                      def format_basic_line(input, indent)
         | 
| 18 | 
            +
                        result = "#{indent}• #{input_type_display(input)}"
         | 
| 19 | 
            +
                        result += input_id_display(input)
         | 
| 20 | 
            +
                        result += input_value_display(input)
         | 
| 21 | 
            +
                        result += input_placeholder_display(input)
         | 
| 22 | 
            +
                        "#{result}\n"
         | 
| 23 | 
            +
                      end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                      def format_selectors_line(input, indent)
         | 
| 26 | 
            +
                        selectors = SelectorGenerator.generate_stable_selectors(input)
         | 
| 27 | 
            +
                        return "" if selectors.empty?
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                        format_selector_list(selectors, indent)
         | 
| 30 | 
            +
                      end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                      def format_selector_list(selectors, indent)
         | 
| 33 | 
            +
                        result = "#{indent}  Stable selectors:\n"
         | 
| 34 | 
            +
                        selectors.each { |sel| result += "#{indent}    - #{sel}\n" }
         | 
| 35 | 
            +
                        "#{result}\n"
         | 
| 36 | 
            +
                      end
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                      def input_type_display(input)
         | 
| 39 | 
            +
                        (input["type"] || input.name).capitalize
         | 
| 40 | 
            +
                      end
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                      def input_id_display(input)
         | 
| 43 | 
            +
                        input["id"] ? " (#{input['id']})" : ""
         | 
| 44 | 
            +
                      end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                      def input_value_display(input)
         | 
| 47 | 
            +
                        value = input["value"]
         | 
| 48 | 
            +
                        value && !value.empty? ? " = '#{value}'" : ""
         | 
| 49 | 
            +
                      end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                      def input_placeholder_display(input)
         | 
| 52 | 
            +
                        placeholder = input["placeholder"]
         | 
| 53 | 
            +
                        placeholder && !placeholder.empty? ? " [#{placeholder}]" : ""
         | 
| 54 | 
            +
                      end
         | 
| 55 | 
            +
                    end
         | 
| 56 | 
            +
                  end
         | 
| 57 | 
            +
                end
         | 
| 58 | 
            +
              end
         | 
| 59 | 
            +
            end
         |