voice_control 0.1.0
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 +7 -0
- data/CHANGELOG.md +55 -0
- data/CONTRIBUTING.md +56 -0
- data/LICENSE +21 -0
- data/README.md +343 -0
- data/SECURITY.md +15 -0
- data/app/controllers/voice_control/assets_controller.rb +20 -0
- data/app/controllers/voice_control/commands_controller.rb +77 -0
- data/assets/widget.css +346 -0
- data/assets/widget.js +1910 -0
- data/config/routes.rb +7 -0
- data/docs/browser-actions.md +112 -0
- data/docs/commands.md +113 -0
- data/docs/configuration.md +40 -0
- data/docs/demo.md +39 -0
- data/docs/deployment.md +103 -0
- data/docs/integration.md +79 -0
- data/examples/react.jsx +27 -0
- data/lib/generators/voice_control/install/install_generator.rb +21 -0
- data/lib/generators/voice_control/install/templates/voice_control.rb +72 -0
- data/lib/voice_control/argument.rb +59 -0
- data/lib/voice_control/browser_actions.rb +210 -0
- data/lib/voice_control/command.rb +51 -0
- data/lib/voice_control/configuration.rb +71 -0
- data/lib/voice_control/conversation.rb +217 -0
- data/lib/voice_control/engine.rb +11 -0
- data/lib/voice_control/jev.rb +60 -0
- data/lib/voice_control/result.rb +59 -0
- data/lib/voice_control/version.rb +3 -0
- data/lib/voice_control/widget_helper.rb +20 -0
- data/lib/voice_control.rb +29 -0
- metadata +153 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
VoiceControl.configure do |config|
|
|
2
|
+
# Server-side Jev API key; accepts a string or a zero-argument callable.
|
|
3
|
+
config.api_key = ENV["JEV_API_KEY"]
|
|
4
|
+
|
|
5
|
+
# Jev model used to match natural-language commands to your vocabulary.
|
|
6
|
+
config.model = "jev-latest"
|
|
7
|
+
|
|
8
|
+
# Engine endpoints inherit this controller's authentication and callbacks.
|
|
9
|
+
config.parent_controller = "ApplicationController"
|
|
10
|
+
|
|
11
|
+
# Controls access to the widget and endpoints. Replace with your own access check.
|
|
12
|
+
# For example: -> { current_user&.admin? }. Access is denied until configured.
|
|
13
|
+
config.authorize = -> { false }
|
|
14
|
+
|
|
15
|
+
# Binds execution tickets to an account as well as its session; defaults to current_user.id when available.
|
|
16
|
+
# Override this for another authentication system.
|
|
17
|
+
# config.identity = -> { current_account.id }
|
|
18
|
+
|
|
19
|
+
# Filters browser-supplied context before matching; defaults to passing it through.
|
|
20
|
+
# Keep only the keys your commands need, especially if URLs contain private query parameters.
|
|
21
|
+
# config.context = ->(client_context) { client_context.slice("path") }
|
|
22
|
+
|
|
23
|
+
# Discovers labeled page controls for click, fill, select, check, scroll and submit commands.
|
|
24
|
+
# Sends control labels/IDs and dropdown labels to Jev; existing field values are excluded.
|
|
25
|
+
config.browser_actions = false
|
|
26
|
+
|
|
27
|
+
# Open/close shortcut; mod is Cmd on macOS and Ctrl elsewhere. Set nil to disable.
|
|
28
|
+
config.keyboard_shortcut = "mod+shift+u"
|
|
29
|
+
|
|
30
|
+
# Hold to speak, release to submit. Set nil to disable this shortcut.
|
|
31
|
+
config.push_to_talk_shortcut = "mod+shift+space"
|
|
32
|
+
|
|
33
|
+
# Speech recognition language as a BCP 47 tag, e.g. "uk-UA". Widget text and built-in page phrases stay English.
|
|
34
|
+
config.speech_language = "en-US"
|
|
35
|
+
|
|
36
|
+
# Corner for the launcher and panel: :bottom_right or :bottom_left.
|
|
37
|
+
config.widget_position = :bottom_right
|
|
38
|
+
|
|
39
|
+
# :small uses a 44px button and 20px icon; :normal uses 56px and 24px. Panel width is unchanged.
|
|
40
|
+
config.launcher_size = :normal
|
|
41
|
+
|
|
42
|
+
# Close and stop listening after this many milliseconds of inactivity; use a positive number.
|
|
43
|
+
config.idle_timeout = 120_000
|
|
44
|
+
|
|
45
|
+
# Maximum milliseconds per request, including reading its response; execution timeouts never retry automatically.
|
|
46
|
+
config.request_timeout = 30_000
|
|
47
|
+
|
|
48
|
+
# Matches below this confidence ask for disambiguation. Calibrate for your vocabulary.
|
|
49
|
+
config.confidence_threshold = 0.35
|
|
50
|
+
|
|
51
|
+
# Shows the latest transcript, matching details, Jev result and execution timing in the widget.
|
|
52
|
+
# Enable for trusted users while troubleshooting; copied details may contain private data.
|
|
53
|
+
config.debug = false
|
|
54
|
+
|
|
55
|
+
# Defaults to Rails.cache, with a MemoryStore fallback for development NullStore.
|
|
56
|
+
# Production needs a shared atomic cache for replay protection; see docs/deployment.md.
|
|
57
|
+
# config.execution_store = -> { Rails.cache }
|
|
58
|
+
|
|
59
|
+
# Optional matcher accepting transcript:, context:, commands:; nil uses Jev.
|
|
60
|
+
# Return command:, confidence:, candidates: without executing an action; see docs/configuration.md.
|
|
61
|
+
config.interpreter = nil
|
|
62
|
+
|
|
63
|
+
# Receives unexpected errors and command/controller details; the UI shows a generic message.
|
|
64
|
+
# The default logs only the error class and command key. Replace with your error tracker if needed.
|
|
65
|
+
# config.on_error = ->(error, details) { Rails.logger.error("VoiceControl #{error.class} command=#{details[:command]}") }
|
|
66
|
+
|
|
67
|
+
config.group "Navigation" do
|
|
68
|
+
config.command :home, description: "Open home", aliases: ["go home"], examples: ["open home"] do
|
|
69
|
+
execute { |_args, _context| VoiceControl::Result.navigate(main_app.root_path) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "bigdecimal"
|
|
2
|
+
|
|
3
|
+
module VoiceControl
|
|
4
|
+
class Argument
|
|
5
|
+
TYPES = [:string, :integer, :decimal, :boolean, :enum].freeze
|
|
6
|
+
attr_reader :name, :type, :prompt
|
|
7
|
+
|
|
8
|
+
def initialize(name, type, required: true, default: nil, extract: nil, validate: nil, values: nil, prompt: nil)
|
|
9
|
+
raise ArgumentError, "Unknown argument type: #{type}" unless TYPES.include?(type)
|
|
10
|
+
raise ArgumentError, "Enum requires values" if type == :enum && (values.nil? || values.empty?)
|
|
11
|
+
|
|
12
|
+
@name, @type, @required = name.to_s, type, required
|
|
13
|
+
@default, @extractor, @validator, @values = default, extract, validate, values&.map(&:to_s)
|
|
14
|
+
@prompt = prompt || "What is the #{name.to_s.tr('_', ' ')}?#{@values ? " Choose: #{@values.join(', ')}." : ''}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initial_value(controller, transcript, context)
|
|
18
|
+
value = controller.instance_exec(transcript, context, &@extractor) if @extractor
|
|
19
|
+
if value.nil?
|
|
20
|
+
value = @default.respond_to?(:call) ? controller.instance_exec(transcript, context, &@default) : @default
|
|
21
|
+
end
|
|
22
|
+
value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def coerce(value, controller:)
|
|
26
|
+
return nil if value.nil? && !@required
|
|
27
|
+
raise InvalidInput, prompt if value.nil? || value.to_s.strip.empty? || value.to_s.length > 2_000
|
|
28
|
+
|
|
29
|
+
text = value.to_s.strip
|
|
30
|
+
result = case type
|
|
31
|
+
when :integer
|
|
32
|
+
raise ArgumentError unless text.match?(/\A[+-]?\d+\z/)
|
|
33
|
+
Integer(text, 10)
|
|
34
|
+
when :decimal
|
|
35
|
+
raise ArgumentError unless text.match?(/\A[+-]?\d+(?:\.\d+)?\z/)
|
|
36
|
+
BigDecimal(text).to_s("F")
|
|
37
|
+
when :boolean
|
|
38
|
+
case text.downcase
|
|
39
|
+
when "true", "yes", "on" then true
|
|
40
|
+
when "false", "no", "off" then false
|
|
41
|
+
else raise ArgumentError
|
|
42
|
+
end
|
|
43
|
+
when :enum
|
|
44
|
+
@values.find { |option| option.tr("_", " ").casecmp?(text.tr("_", " ")) } || (raise ArgumentError)
|
|
45
|
+
else
|
|
46
|
+
text
|
|
47
|
+
end
|
|
48
|
+
raise ArgumentError if @validator && !controller.instance_exec(result, &@validator)
|
|
49
|
+
|
|
50
|
+
result
|
|
51
|
+
rescue ArgumentError, TypeError
|
|
52
|
+
raise InvalidInput, prompt
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def as_json(*)
|
|
56
|
+
{ name: name, type: type, required: @required, values: @values, prompt: prompt }.compact
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
module VoiceControl
|
|
2
|
+
class BrowserActions
|
|
3
|
+
MAX_TARGETS = 200
|
|
4
|
+
PREFIX = "voice_control_browser_".freeze
|
|
5
|
+
SCROLL_DIRECTIONS = %w[up down top bottom].freeze
|
|
6
|
+
HISTORY_DIRECTIONS = %w[back forward].freeze
|
|
7
|
+
attr_reader :commands
|
|
8
|
+
|
|
9
|
+
def initialize(page)
|
|
10
|
+
unless page.is_a?(Hash) && JSON.generate(page).bytesize <= 131_072 &&
|
|
11
|
+
page["page_id"].is_a?(String) && page["page_id"].match?(/\A[a-z0-9-]{1,64}\z/i) &&
|
|
12
|
+
page["elements"].is_a?(Array) && page["elements"].length <= MAX_TARGETS
|
|
13
|
+
raise InvalidInput, "Invalid browser controls. Reload the page and try again."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@page_id = page["page_id"]
|
|
17
|
+
@targets = page["elements"].map { |target| validate_target(target) }
|
|
18
|
+
raise InvalidInput, "Duplicate browser controls" unless @targets.map { |target| target["ref"] }.uniq.length == @targets.length
|
|
19
|
+
|
|
20
|
+
@selected_ref = page["selected_ref"]
|
|
21
|
+
if @selected_ref && !@targets.any? { |target| target["ref"] == @selected_ref && (target["actions"] & %w[fill select]).any? }
|
|
22
|
+
raise InvalidInput, "Select an editable field first."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@command_targets = {}
|
|
26
|
+
@commands = @targets.flat_map { |target| build_commands(target) } + build_scroll_commands + build_history_commands + [build_submit_command]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def snapshot(keys)
|
|
30
|
+
refs = keys.filter_map { |key| @command_targets[key] }
|
|
31
|
+
return if refs.empty? && keys.none? { |key|
|
|
32
|
+
key == "#{PREFIX}submit" || SCROLL_DIRECTIONS.any? { |direction| key == "#{PREFIX}scroll_#{direction}" } ||
|
|
33
|
+
HISTORY_DIRECTIONS.any? { |direction| key == "#{PREFIX}history_#{direction}" }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
{ "page_id" => @page_id, "elements" => @targets.select { |target| refs.include?(target["ref"]) },
|
|
37
|
+
"selected_ref" => (@selected_ref if refs.include?(@selected_ref)) }.compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def exact_click_matches(transcript)
|
|
41
|
+
phrase = transcript.squish.downcase
|
|
42
|
+
@commands.select do |command|
|
|
43
|
+
command.key.start_with?("#{PREFIX}click_") &&
|
|
44
|
+
command.aliases.any? { |name| name.start_with?("click ") && name.squish.downcase == phrase }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def extract_value(text, label:)
|
|
50
|
+
text[/["“](.+?)["”]/, 1] ||
|
|
51
|
+
text[/\A(?:fill|enter|type|set)\s+(?:the\s+)?#{Regexp.escape(label)}(?:\s+field)?\s+(?:with|to)\s+(.+)\z/i, 1] ||
|
|
52
|
+
text[/\b(?:fill|set)\b.+?\b(?:with|to)\s+(.+)\z/i, 1] ||
|
|
53
|
+
text[/\b(?:enter|type)\s+(.+?)\s+(?:in|into)\s+.+\z/i, 1]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def selected_value(text)
|
|
57
|
+
text[/\A(?:enter|type)\s+(.+)\z/im, 1]&.sub(/\A"(.*)"\z/m, '\1')&.sub(/\A“(.*)”\z/m, '\1')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def select_value(text, label:)
|
|
61
|
+
extract_value(text, label: label) ||
|
|
62
|
+
text[/\A(?:select|choose)\s+(.+?)\s+(?:from|in|for)\s+(?:the\s+)?#{Regexp.escape(label)}\z/i, 1] ||
|
|
63
|
+
(text[/\A(?:select|choose)\s+(.+)\z/i, 1] unless text.match?(/\A(?:select|choose)\s+#{Regexp.escape(label)}\z/i))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def validate_target(target)
|
|
70
|
+
unless target.is_a?(Hash) && target["ref"].is_a?(String) && target["ref"].match?(/\Ae\d{1,9}\z/)
|
|
71
|
+
raise InvalidInput, "Invalid browser control reference"
|
|
72
|
+
end
|
|
73
|
+
clean = target.slice("ref", "label", "id", "name", "tag", "type")
|
|
74
|
+
unless %w[label id name tag type].all? { |key| clean[key].is_a?(String) && clean[key].length <= 160 } && clean["label"].present?
|
|
75
|
+
raise InvalidInput, "Invalid browser control label"
|
|
76
|
+
end
|
|
77
|
+
allowed = case clean["tag"]
|
|
78
|
+
when "button", "a" then %w[click]
|
|
79
|
+
when "textarea" then %w[fill focus clear]
|
|
80
|
+
when "h1", "h2", "h3", "h4", "h5", "h6" then %w[reveal]
|
|
81
|
+
when "select"
|
|
82
|
+
options = target["options"]
|
|
83
|
+
unless options.is_a?(Array) && options.length.between?(1, 100) && JSON.generate(options).bytesize <= 4096 &&
|
|
84
|
+
options.all? { |option| option.is_a?(Hash) && option["ref"].is_a?(String) && option["ref"].match?(/\Ao\d{1,6}\z/) && option["label"].is_a?(String) && option["label"].strip.length.between?(1, 160) }
|
|
85
|
+
raise InvalidInput, "Invalid dropdown options"
|
|
86
|
+
end
|
|
87
|
+
clean["options"] = options.map { |option| option.slice("ref", "label") }
|
|
88
|
+
if options.map { |option| option["ref"] }.uniq.length != options.length ||
|
|
89
|
+
options.map { |option| option["label"].tr("_", " ").downcase }.uniq.length != options.length
|
|
90
|
+
raise InvalidInput, "Dropdown options must have distinct labels."
|
|
91
|
+
end
|
|
92
|
+
%w[select focus]
|
|
93
|
+
when "input"
|
|
94
|
+
if clean["type"] == "checkbox"
|
|
95
|
+
%w[check uncheck]
|
|
96
|
+
elsif clean["type"] == "radio"
|
|
97
|
+
%w[choose]
|
|
98
|
+
elsif %w[button submit reset].include?(clean["type"])
|
|
99
|
+
%w[click]
|
|
100
|
+
elsif %w[text search email tel url number date time].include?(clean["type"])
|
|
101
|
+
%w[fill focus clear]
|
|
102
|
+
else
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
clean["type"] == "button" ? %w[click] : []
|
|
107
|
+
end
|
|
108
|
+
raise InvalidInput, "Unsupported browser control" if allowed.empty?
|
|
109
|
+
|
|
110
|
+
clean.merge("actions" => allowed)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_commands(target)
|
|
114
|
+
actions = target["actions"] + (target["ref"] == @selected_ref ? ["enter"] : [])
|
|
115
|
+
actions << "clear_selected" if target["ref"] == @selected_ref && target["actions"].include?("clear")
|
|
116
|
+
actions.map do |action|
|
|
117
|
+
selected = %w[enter clear_selected].include?(action)
|
|
118
|
+
fill = %w[fill enter].include?(action)
|
|
119
|
+
key = "#{PREFIX}#{action}_#{target['ref']}"
|
|
120
|
+
@command_targets[key] = target["ref"]
|
|
121
|
+
next build_checked_command(target, key, action) if %w[check uncheck choose].include?(action)
|
|
122
|
+
next build_dropdown_command(target, key, selected: selected) if action == "select" || (selected && target["tag"] == "select")
|
|
123
|
+
label = target["label"]
|
|
124
|
+
identity = [target["tag"], ("id: #{target['id']}" if target["id"].present?), ("name: #{target['name']}" if target["name"].present?)].compact.join(", ")
|
|
125
|
+
result_action = { "enter" => "fill", "clear_selected" => "clear" }.fetch(action, action)
|
|
126
|
+
result = { kind: "browser", action: result_action, target: target["ref"], page_id: @page_id }
|
|
127
|
+
result[:selected] = true if selected
|
|
128
|
+
aliases = (action == "fill" ? %w[fill enter type set] : [action]).map { |verb| "#{verb} #{label}" }
|
|
129
|
+
aliases << label if action == "click" && label.match?(/\A(?:edit|delete|remove|view|open|manage)\s/i)
|
|
130
|
+
aliases = ["clear this field", "clear selected field", "clear the selected field"] if action == "clear_selected"
|
|
131
|
+
aliases = ["show #{label}", "show the #{label} section", "scroll to #{label}", "go to #{label} section"] if action == "reveal"
|
|
132
|
+
description = if selected
|
|
133
|
+
"#{action == 'enter' ? 'Enter a value into' : 'Clear'} the selected field: #{label}"
|
|
134
|
+
elsif action == "reveal"
|
|
135
|
+
"Show #{label} section"
|
|
136
|
+
else
|
|
137
|
+
"#{action.capitalize} #{label} (#{identity})"
|
|
138
|
+
end
|
|
139
|
+
Command.new(key, description: description, group: "On this page",
|
|
140
|
+
aliases: action == "enter" ? %w[enter type] : aliases, examples: []) do
|
|
141
|
+
if fill
|
|
142
|
+
argument :value, :string, prompt: "What should I enter in #{label}?",
|
|
143
|
+
extract: ->(text, _context) { selected ? BrowserActions.selected_value(text) : BrowserActions.extract_value(text, label: label) }
|
|
144
|
+
end
|
|
145
|
+
execute { |args, _context| fill ? result.merge(value: args[:value]) : result }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_dropdown_command(target, key, selected:)
|
|
151
|
+
page_id = @page_id
|
|
152
|
+
label = target["label"]
|
|
153
|
+
options = target["options"]
|
|
154
|
+
values = options.map { |option| option["label"] }
|
|
155
|
+
aliases = selected ? %w[enter type] : ["set #{label}", "select #{label}", "choose #{label}"] + values.flat_map { |value| ["select #{value}", "choose #{value}"] }
|
|
156
|
+
description = selected ? "Enter a choice into the selected dropdown: #{label}" : "Select #{label}"
|
|
157
|
+
Command.new(key, description: description, group: "On this page", aliases: aliases) do
|
|
158
|
+
argument :value, :enum, values: values,
|
|
159
|
+
prompt: "Which #{label}? Choose: #{values.join(', ')}.",
|
|
160
|
+
extract: ->(text, _context) { selected ? BrowserActions.selected_value(text) : BrowserActions.select_value(text, label: label) }
|
|
161
|
+
execute do |args, _context|
|
|
162
|
+
result = { kind: "browser", action: "select", target: target["ref"], page_id: page_id,
|
|
163
|
+
option: options.find { |option| option["label"] == args[:value] }.fetch("ref") }
|
|
164
|
+
result[:selected] = true if selected
|
|
165
|
+
result
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def build_checked_command(target, key, action)
|
|
171
|
+
label = target["label"]
|
|
172
|
+
subject = label.sub(/\A(?:enable|disable)\s+/i, "")
|
|
173
|
+
verbs = { "check" => ["check", "enable", "turn on"], "uncheck" => ["uncheck", "disable", "turn off"], "choose" => ["choose", "select", "check"] }.fetch(action)
|
|
174
|
+
aliases = verbs.product([label, subject].uniq).map { |verb, name| "#{verb} #{name}" }
|
|
175
|
+
result = { kind: "browser", action: action, target: target["ref"], page_id: @page_id }
|
|
176
|
+
Command.new(key, description: "#{action.capitalize} #{label}", group: "On this page", aliases: aliases) do
|
|
177
|
+
execute { |_args, _context| result }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_scroll_commands
|
|
182
|
+
page_id = @page_id
|
|
183
|
+
SCROLL_DIRECTIONS.map do |direction|
|
|
184
|
+
aliases = ["scroll #{direction}", "scroll to #{direction}", "scroll to the #{direction}"]
|
|
185
|
+
aliases += ["back to top", "go to top"] if direction == "top"
|
|
186
|
+
Command.new("#{PREFIX}scroll_#{direction}", description: "Scroll #{direction}", group: "On this page", aliases: aliases) do
|
|
187
|
+
execute { |_args, _context| { kind: "browser", action: "scroll", direction: direction, page_id: page_id } }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_history_commands
|
|
193
|
+
page_id = @page_id
|
|
194
|
+
HISTORY_DIRECTIONS.map do |direction|
|
|
195
|
+
Command.new("#{PREFIX}history_#{direction}", description: "Go #{direction}", group: "On this page",
|
|
196
|
+
aliases: ["go #{direction}", "browser #{direction}", direction]) do
|
|
197
|
+
execute { |_args, _context| { kind: "browser", action: "history", direction: direction, page_id: page_id } }
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def build_submit_command
|
|
203
|
+
page_id = @page_id
|
|
204
|
+
Command.new("#{PREFIX}submit", description: "Submit active form", group: "On this page",
|
|
205
|
+
aliases: ["submit", "submit form", "submit this form", "submit active form", "submit this field"]) do
|
|
206
|
+
execute { |_args, _context| { kind: "browser", action: "submit", page_id: page_id } }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module VoiceControl
|
|
2
|
+
class Command
|
|
3
|
+
attr_reader :key, :description, :group, :aliases, :examples, :arguments, :executor
|
|
4
|
+
|
|
5
|
+
def initialize(key, description:, group:, aliases: [], examples: [], pages: nil, visible: -> { true }, authorize: ->(_args, _context) { true }, &block)
|
|
6
|
+
@key, @description, @group = key, description, group
|
|
7
|
+
@aliases, @examples = aliases, examples
|
|
8
|
+
@visibility, @authorization = visible, authorize
|
|
9
|
+
@pages = pages.nil? ? nil : Array(pages)
|
|
10
|
+
if @pages && (@pages.empty? || !@pages.all? { |page| page.is_a?(Regexp) || local_path?(page) })
|
|
11
|
+
raise ArgumentError, "Command pages must be local paths or regular expressions"
|
|
12
|
+
end
|
|
13
|
+
@arguments = []
|
|
14
|
+
instance_eval(&block)
|
|
15
|
+
raise ArgumentError, "Command #{key} needs an execute block" unless executor
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def argument(name, type, **options)
|
|
19
|
+
raise ArgumentError, "Duplicate argument: #{name}" if arguments.any? { |argument| argument.name == name.to_s }
|
|
20
|
+
|
|
21
|
+
arguments << Argument.new(name, type, **options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def execute(&block)
|
|
25
|
+
@executor = block
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def visible?(controller)
|
|
29
|
+
!!controller.instance_exec(&@visibility)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def available_on?(path)
|
|
33
|
+
!@pages || (local_path?(path) && @pages.any? { |page| page.is_a?(Regexp) ? page.match?(path) : page == path })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def allowed?(controller, args, context)
|
|
37
|
+
visible?(controller) && !!controller.instance_exec(args, context, &@authorization)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def as_json(*)
|
|
41
|
+
{ key: key, description: description, group: group, aliases: aliases, examples: examples,
|
|
42
|
+
arguments: arguments.map(&:as_json) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def local_path?(path)
|
|
48
|
+
path.is_a?(String) && path.length <= 2_048 && path.match?(%r{\A/(?!/)[^?#\s\\]*\z})
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module VoiceControl
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :api_key, :model, :parent_controller, :authorize, :context,
|
|
4
|
+
:on_error, :interpreter, :keyboard_shortcut, :idle_timeout, :confidence_threshold,
|
|
5
|
+
:execution_store, :identity, :browser_actions, :debug, :push_to_talk_shortcut
|
|
6
|
+
attr_reader :commands, :widget_position, :launcher_size, :request_timeout, :speech_language
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@model = "jev-latest"
|
|
10
|
+
@parent_controller = "ApplicationController"
|
|
11
|
+
@authorize = -> { false }
|
|
12
|
+
@identity = -> { respond_to?(:current_user, true) ? current_user&.id : nil }
|
|
13
|
+
@context = ->(client_context) { client_context }
|
|
14
|
+
@on_error = ->(error, details) { Rails.logger.error("VoiceControl #{error.class} command=#{details[:command]}") }
|
|
15
|
+
@keyboard_shortcut = "mod+shift+u"
|
|
16
|
+
@idle_timeout = 120_000
|
|
17
|
+
@request_timeout = 30_000
|
|
18
|
+
@confidence_threshold = 0.35
|
|
19
|
+
development_store = ActiveSupport::Cache::MemoryStore.new if Rails.env.development?
|
|
20
|
+
@execution_store = -> { Rails.cache.is_a?(ActiveSupport::Cache::NullStore) && development_store ? development_store : Rails.cache }
|
|
21
|
+
@commands = {}
|
|
22
|
+
@browser_actions = false
|
|
23
|
+
@debug = false
|
|
24
|
+
@widget_position = :bottom_right
|
|
25
|
+
@launcher_size = :normal
|
|
26
|
+
@push_to_talk_shortcut = "mod+shift+space"
|
|
27
|
+
@speech_language = "en-US"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def speech_language=(value)
|
|
31
|
+
raise ArgumentError, "Speech language must be a BCP 47 tag such as en-US or uk-UA" unless value.is_a?(String) && value.match?(/\A[a-z]{2,3}(?:-[a-z0-9]{1,8})*\z/i)
|
|
32
|
+
|
|
33
|
+
@speech_language = value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def widget_position=(value)
|
|
37
|
+
raise ArgumentError, "Widget position must be bottom_right or bottom_left" unless %w[bottom_right bottom_left].include?(value.to_s)
|
|
38
|
+
|
|
39
|
+
@widget_position = value.to_sym
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def request_timeout=(value)
|
|
43
|
+
raise ArgumentError, "Request timeout must be an integer from 1,000 to 300,000 milliseconds" unless value.is_a?(Integer) && value.between?(1_000, 300_000)
|
|
44
|
+
|
|
45
|
+
@request_timeout = value
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def launcher_size=(value)
|
|
49
|
+
raise ArgumentError, "Launcher size must be normal or small" unless %w[normal small].include?(value.to_s)
|
|
50
|
+
|
|
51
|
+
@launcher_size = value.to_sym
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def group(name, &block)
|
|
55
|
+
previous = @group
|
|
56
|
+
@group = name.to_s
|
|
57
|
+
block.call(self)
|
|
58
|
+
ensure
|
|
59
|
+
@group = previous
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def command(key, **options, &block)
|
|
63
|
+
key = key.to_s
|
|
64
|
+
raise ArgumentError, "Duplicate command: #{key}" if commands.key?(key)
|
|
65
|
+
raise ArgumentError, "Invalid command key" unless key.match?(/\A[a-z][a-z0-9_]*\z/) && key != "none"
|
|
66
|
+
raise ArgumentError, "Reserved browser command key" if key.start_with?(BrowserActions::PREFIX)
|
|
67
|
+
|
|
68
|
+
commands[key] = Command.new(key, group: @group || "Commands", **options, &block)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|