poetry-agent 0.0.2
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 +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
- data/app/javascript/poetry/agent/adapter.js +77 -0
- data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
- data/app/javascript/poetry/agent/index.js +41 -0
- data/app/javascript/poetry/agent/stream_actions.js +65 -0
- data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
- data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
- data/config/controllers_manifest.json +82 -0
- data/config/importmap.rb +10 -0
- data/exe/poetry-agent +28 -0
- data/lib/poetry/agent/a2ui/catalog.rb +289 -0
- data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
- data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
- data/lib/poetry/agent/a2ui/checks.rb +45 -0
- data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
- data/lib/poetry/agent/a2ui/expression.rb +175 -0
- data/lib/poetry/agent/a2ui/functions.rb +417 -0
- data/lib/poetry/agent/a2ui/markdown.rb +63 -0
- data/lib/poetry/agent/a2ui/pointer.rb +113 -0
- data/lib/poetry/agent/a2ui/protocol.rb +12 -0
- data/lib/poetry/agent/a2ui/renderer.rb +242 -0
- data/lib/poetry/agent/a2ui/session.rb +302 -0
- data/lib/poetry/agent/a2ui/streams.rb +82 -0
- data/lib/poetry/agent/a2ui/surface.rb +352 -0
- data/lib/poetry/agent/a2ui.rb +48 -0
- data/lib/poetry/agent/agui/client.rb +69 -0
- data/lib/poetry/agent/agui/json_patch.rb +137 -0
- data/lib/poetry/agent/agui/relay.rb +105 -0
- data/lib/poetry/agent/agui/run_input.rb +83 -0
- data/lib/poetry/agent/agui/sse.rb +97 -0
- data/lib/poetry/agent/agui/transcript.rb +540 -0
- data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
- data/lib/poetry/agent/agui.rb +87 -0
- data/lib/poetry/agent/config.rb +49 -0
- data/lib/poetry/agent/engine.rb +37 -0
- data/lib/poetry/agent/mcp/bundled.rb +54 -0
- data/lib/poetry/agent/mcp/http.rb +89 -0
- data/lib/poetry/agent/mcp/server.rb +962 -0
- data/lib/poetry/agent/version.rb +8 -0
- data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
- data/lib/poetry/agent/webmcp.rb +37 -0
- data/lib/poetry/agent.rb +66 -0
- data/lib/poetry-agent.rb +4 -0
- metadata +117 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../pointer"
|
|
4
|
+
require_relative "../markdown"
|
|
5
|
+
|
|
6
|
+
module Poetry
|
|
7
|
+
module Agent
|
|
8
|
+
module A2UI
|
|
9
|
+
# The catalog bindings a {Surface} renders through: how a catalog's
|
|
10
|
+
# components reference their children, which of them are bound
|
|
11
|
+
# inputs, and how each one renders with Poetry.
|
|
12
|
+
module Catalogs
|
|
13
|
+
# The A2UI basic catalog (v1.0) rendered with Poetry: every one of
|
|
14
|
+
# its components maps onto a library component or a plain element,
|
|
15
|
+
# its enums onto the library's axes, its bound inputs onto named
|
|
16
|
+
# form controls, and its `checks` onto native constraint
|
|
17
|
+
# attributes where the browser can enforce them.
|
|
18
|
+
class Basic
|
|
19
|
+
# The catalog id agents name in `createSurface`.
|
|
20
|
+
ID = "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json"
|
|
21
|
+
|
|
22
|
+
# Main-axis alignment classes by the catalog's `justify` values.
|
|
23
|
+
JUSTIFY = { "start" => "justify-start", "center" => "justify-center", "end" => "justify-end",
|
|
24
|
+
"spaceBetween" => "justify-between", "spaceAround" => "justify-around",
|
|
25
|
+
"spaceEvenly" => "justify-evenly", "stretch" => "justify-stretch" }.freeze
|
|
26
|
+
# Cross-axis alignment classes by the catalog's `align` values.
|
|
27
|
+
ALIGN = { "start" => "items-start", "center" => "items-center", "end" => "items-end",
|
|
28
|
+
"stretch" => "items-stretch" }.freeze
|
|
29
|
+
# Object-fit classes by the Image `fit` values.
|
|
30
|
+
FIT = { "contain" => "object-contain", "cover" => "object-cover", "fill" => "object-fill",
|
|
31
|
+
"none" => "object-none", "scaleDown" => "object-scale-down" }.freeze
|
|
32
|
+
# Sizing classes by the Image `variant` values.
|
|
33
|
+
IMAGE_VARIANTS = { "icon" => "size-6", "avatar" => "size-10 rounded-full",
|
|
34
|
+
"smallFeature" => "w-full max-w-32", "mediumFeature" => "w-full max-w-sm",
|
|
35
|
+
"largeFeature" => "w-full max-w-2xl", "header" => "w-full" }.freeze
|
|
36
|
+
# Poetry's Button variant by the catalog's Button `variant` values.
|
|
37
|
+
BUTTON_VARIANTS = { "primary" => :default, "default" => :outline, "borderless" => :ghost }.freeze
|
|
38
|
+
# Lucide names for the Material-style icon names agents tend to
|
|
39
|
+
# emit; anything else converts camelCase to kebab-case as is.
|
|
40
|
+
ICON_ALIASES = { "skip-previous" => "skip-back", "skip-next" => "skip-forward", "play-arrow" => "play",
|
|
41
|
+
"favorite" => "heart", "favorite-border" => "heart", "close" => "x",
|
|
42
|
+
"arrow-back" => "arrow-left", "arrow-forward" => "arrow-right",
|
|
43
|
+
"more-vert" => "ellipsis-vertical", "more-horiz" => "ellipsis", "delete" => "trash-2",
|
|
44
|
+
"edit" => "pencil", "add" => "plus", "remove" => "minus", "notifications" => "bell",
|
|
45
|
+
"person" => "user", "email" => "mail", "schedule" => "clock", "event" => "calendar",
|
|
46
|
+
"location-on" => "map-pin", "visibility" => "eye", "visibility-off" => "eye-off",
|
|
47
|
+
"home" => "house", "shopping-cart" => "shopping-cart" }.freeze
|
|
48
|
+
# A ChoicePicker's resolved options, binding, selection, and label.
|
|
49
|
+
Choice = Struct.new(:options, :path, :selected, :label, keyword_init: true)
|
|
50
|
+
# The bound kinds of the input components.
|
|
51
|
+
INPUT_KINDS = { "TextField" => :string, "CheckBox" => :boolean, "Slider" => :number,
|
|
52
|
+
"ChoicePicker" => :string_list, "DateTimeInput" => :string }.freeze
|
|
53
|
+
|
|
54
|
+
# @return [String]
|
|
55
|
+
def id
|
|
56
|
+
ID
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Functions] the basic catalog's function set
|
|
60
|
+
def functions
|
|
61
|
+
Functions.basic
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The child references of a component (ids, id lists, templates).
|
|
65
|
+
#
|
|
66
|
+
# @param component [Hash]
|
|
67
|
+
# @return [Array<Object>]
|
|
68
|
+
def references(component)
|
|
69
|
+
case component["component"]
|
|
70
|
+
when "Row", "Column", "List" then [component["children"]]
|
|
71
|
+
when "Card", "Button" then [component["child"]]
|
|
72
|
+
when "Modal" then [component["trigger"], component["content"]]
|
|
73
|
+
when "Tabs" then Array(component["tabs"]).map { |tab| tab.is_a?(Hash) ? tab["child"] : nil }
|
|
74
|
+
else []
|
|
75
|
+
end.compact
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The bound input of a component, if it is one.
|
|
79
|
+
#
|
|
80
|
+
# @param component [Hash]
|
|
81
|
+
# @param scope [String, nil]
|
|
82
|
+
# @return [Array<Hash>] `{ path:, kind: }`
|
|
83
|
+
def inputs(component, scope)
|
|
84
|
+
kind = INPUT_KINDS[component["component"]]
|
|
85
|
+
path = binding_path(component["value"])
|
|
86
|
+
return [] unless kind && path
|
|
87
|
+
|
|
88
|
+
kind = :number if component["component"] == "TextField" && component["variant"] == "number"
|
|
89
|
+
[{ path: Pointer.absolute(path, scope), kind: kind }]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param component [Hash]
|
|
93
|
+
# @param scope [String, nil]
|
|
94
|
+
# @param renderer [Renderer]
|
|
95
|
+
# @return [String] HTML
|
|
96
|
+
def render(component, scope, renderer)
|
|
97
|
+
name = component["component"]
|
|
98
|
+
method = :"render_#{name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}"
|
|
99
|
+
return renderer.warn("unknown basic catalog component #{name.inspect}") unless respond_to?(method, true)
|
|
100
|
+
|
|
101
|
+
send(method, component, scope, renderer)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def render_text(component, scope, renderer)
|
|
107
|
+
text = renderer.text(component["text"], scope)
|
|
108
|
+
if component["variant"] == "caption"
|
|
109
|
+
renderer.view.tag.div(renderer.markdown(text), class: "text-sm text-muted-foreground")
|
|
110
|
+
else
|
|
111
|
+
renderer.component(Poetry::Ui::Typeset::Component) { renderer.markdown(text) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def render_image(component, scope, renderer)
|
|
116
|
+
url = renderer.text(component["url"], scope)
|
|
117
|
+
return renderer.blank if url.empty?
|
|
118
|
+
|
|
119
|
+
classes = ["rounded-md", FIT.fetch(component["fit"], "object-fill"),
|
|
120
|
+
IMAGE_VARIANTS.fetch(component["variant"], IMAGE_VARIANTS["mediumFeature"])]
|
|
121
|
+
renderer.view.tag.img(src: url, alt: renderer.text(component["description"], scope),
|
|
122
|
+
class: classes.join(" "), loading: "lazy")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def render_icon(component, scope, renderer)
|
|
126
|
+
name = icon_name(renderer.text(component["name"], scope))
|
|
127
|
+
return renderer.blank if name.empty?
|
|
128
|
+
|
|
129
|
+
renderer.component(Poetry::Ui::Icon::Component, name: name, class: "size-5")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def icon_name(raw)
|
|
133
|
+
kebab = raw.strip.gsub(/([a-z0-9])([A-Z])/, '\1-\2').tr("_ ", "--").downcase
|
|
134
|
+
ICON_ALIASES.fetch(kebab, kebab)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def render_video(component, scope, renderer)
|
|
138
|
+
url = renderer.text(component["url"], scope)
|
|
139
|
+
return renderer.blank if url.empty?
|
|
140
|
+
|
|
141
|
+
poster = renderer.text(component["posterUrl"], scope)
|
|
142
|
+
renderer.view.tag.video(controls: true, src: url, poster: poster.empty? ? nil : poster,
|
|
143
|
+
class: "w-full rounded-md")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def render_audio_player(component, scope, renderer)
|
|
147
|
+
url = renderer.text(component["url"], scope)
|
|
148
|
+
return renderer.blank if url.empty?
|
|
149
|
+
|
|
150
|
+
description = renderer.text(component["description"], scope)
|
|
151
|
+
renderer.view.tag.audio(controls: true, src: url, "aria-label": description.empty? ? nil : description,
|
|
152
|
+
class: "w-full")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def render_row(component, scope, renderer)
|
|
156
|
+
flex(component, scope, renderer, "flex-row")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def render_column(component, scope, renderer)
|
|
160
|
+
flex(component, scope, renderer, "flex-col")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def flex(component, scope, renderer, direction)
|
|
164
|
+
classes = ["flex", direction, "gap-4", JUSTIFY.fetch(component["justify"], "justify-start"),
|
|
165
|
+
ALIGN.fetch(component["align"], "items-stretch")]
|
|
166
|
+
renderer.view.tag.div(weighted_children(component["children"], scope, renderer),
|
|
167
|
+
class: classes.join(" "), "aria-label": renderer.aria_label(component, scope))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A child's `weight` is its flex-grow share of the container.
|
|
171
|
+
def weighted_children(reference, scope, renderer)
|
|
172
|
+
items = renderer.surface.expand(reference, scope).map do |child_id, child_scope|
|
|
173
|
+
html = renderer.render_component(child_id, child_scope)
|
|
174
|
+
weight = renderer.surface.component(child_id)&.fetch("weight", nil)
|
|
175
|
+
weight.is_a?(Numeric) ? renderer.view.tag.div(html, style: "flex: #{weight} 1 0%") : html
|
|
176
|
+
end
|
|
177
|
+
renderer.view.safe_join(items)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def render_list(component, scope, renderer)
|
|
181
|
+
horizontal = component["direction"] == "horizontal"
|
|
182
|
+
classes = ["flex gap-2", horizontal ? "flex-row overflow-x-auto" : "flex-col overflow-y-auto",
|
|
183
|
+
ALIGN.fetch(component["align"], "items-stretch")]
|
|
184
|
+
renderer.view.tag.div(renderer.render_children(component["children"], scope), class: classes.join(" "))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def render_card(component, scope, renderer)
|
|
188
|
+
renderer.component(Poetry::Ui::Card::Component) { renderer.render_children(component["child"], scope) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def render_tabs(component, scope, renderer)
|
|
192
|
+
tabs = Array(component["tabs"]).grep(Hash)
|
|
193
|
+
return renderer.blank if tabs.empty?
|
|
194
|
+
|
|
195
|
+
label = renderer.aria_label(component, scope) || "Tabs"
|
|
196
|
+
renderer.component(Poetry::Ui::Tabs::Component, label: label) do |instance|
|
|
197
|
+
tabs.each_with_index do |tab, index|
|
|
198
|
+
instance.with_tab(renderer.text(tab["title"], scope), value: "tab-#{index}") do
|
|
199
|
+
renderer.render_children(tab["child"], scope)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def render_divider(component, _scope, renderer)
|
|
207
|
+
orientation = component["axis"] == "vertical" ? :vertical : :horizontal
|
|
208
|
+
renderer.component(Poetry::Ui::Separator::Component, orientation: orientation)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The trigger's Button collapses into the dialog's own trigger
|
|
212
|
+
# button (no nested interactives); anything else renders inside it.
|
|
213
|
+
def render_modal(component, scope, renderer)
|
|
214
|
+
trigger = renderer.surface.component(component["trigger"])
|
|
215
|
+
return renderer.warn("Modal #{component["id"].inspect}: unknown trigger") unless trigger
|
|
216
|
+
|
|
217
|
+
title = renderer.aria_label(component, scope) || label_of(trigger, scope, renderer) || "Dialog"
|
|
218
|
+
renderer.component(Poetry::Ui::Dialog::Component) do |dialog|
|
|
219
|
+
dialog.with_trigger(variant: BUTTON_VARIANTS.fetch(trigger["variant"], :outline)) do
|
|
220
|
+
if trigger["component"] == "Button"
|
|
221
|
+
button_content(trigger, scope, renderer)
|
|
222
|
+
else
|
|
223
|
+
renderer.render_component(component["trigger"], scope)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
dialog.with_title { title }
|
|
227
|
+
renderer.render_children(component["content"], scope)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def render_button(component, scope, renderer)
|
|
232
|
+
attributes = { variant: BUTTON_VARIANTS.fetch(component["variant"], :outline) }
|
|
233
|
+
label = renderer.aria_label(component, scope)
|
|
234
|
+
attributes[:aria] = { label: label } if label
|
|
235
|
+
attributes.merge!(action_attributes(component, scope, renderer))
|
|
236
|
+
keyed(component, scope, renderer, attributes)
|
|
237
|
+
button = renderer.component(Poetry::Ui::Button::Component, attributes) do
|
|
238
|
+
button_content(component, scope, renderer)
|
|
239
|
+
end
|
|
240
|
+
with_error(component, scope, renderer, button)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# An agent event submits; `openUrl` links (the function validates
|
|
244
|
+
# the URL); any other local action has no renderer-side meaning.
|
|
245
|
+
def action_attributes(component, scope, renderer)
|
|
246
|
+
action = component["action"]
|
|
247
|
+
return { type: :button } unless action.is_a?(Hash)
|
|
248
|
+
return renderer.submit_attributes(component, scope) if action["event"].is_a?(Hash)
|
|
249
|
+
|
|
250
|
+
call = action["functionCall"]
|
|
251
|
+
return { type: :button } unless call.is_a?(Hash)
|
|
252
|
+
|
|
253
|
+
if call["call"] == "openUrl"
|
|
254
|
+
url = renderer.call_function("openUrl", call["args"], scope)
|
|
255
|
+
return { href: url } if url
|
|
256
|
+
else
|
|
257
|
+
renderer.warn("Button #{component["id"].inspect}: local action #{call["call"].inspect} is not supported")
|
|
258
|
+
end
|
|
259
|
+
{ type: :button, disabled: true }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A checked control carries an error slot the server fills on a
|
|
263
|
+
# rejected action and the client-side evaluator fills as the user
|
|
264
|
+
# types; an unchecked control without a failure renders bare.
|
|
265
|
+
def with_error(component, scope, renderer, control)
|
|
266
|
+
error = renderer.error_for(component, scope)
|
|
267
|
+
return control unless error || component["checks"].is_a?(Array)
|
|
268
|
+
|
|
269
|
+
note = renderer.view.tag.p(error, id: "#{renderer.control_id(component, scope)}-error",
|
|
270
|
+
class: "text-sm text-destructive", role: "alert",
|
|
271
|
+
hidden: error.nil?, data: { a2ui_error_for: renderer.current_key })
|
|
272
|
+
renderer.view.safe_join([control, note])
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The attributes that let the client-side evaluator find a control
|
|
276
|
+
# and read its error slot.
|
|
277
|
+
def keyed(component, scope, renderer, attributes)
|
|
278
|
+
attributes[:data] = (attributes[:data] || {}).merge(a2ui_key: renderer.current_key)
|
|
279
|
+
if component["checks"].is_a?(Array)
|
|
280
|
+
described = "#{renderer.control_id(component, scope)}-error"
|
|
281
|
+
attributes[:aria] = (attributes[:aria] || {}).merge(describedby: described)
|
|
282
|
+
end
|
|
283
|
+
attributes
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# A Text child becomes the button's label (plain, no block markup).
|
|
287
|
+
def button_content(component, scope, renderer)
|
|
288
|
+
label = label_of(component, scope, renderer)
|
|
289
|
+
label || renderer.render_children(component["child"], scope)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def label_of(component, scope, renderer)
|
|
293
|
+
child = renderer.surface.component(component["child"])
|
|
294
|
+
return unless child && child["component"] == "Text"
|
|
295
|
+
|
|
296
|
+
Markdown.strip(renderer.text(child["text"], scope))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def render_text_field(component, scope, renderer)
|
|
300
|
+
path = binding_path(component["value"])
|
|
301
|
+
attributes = { id: renderer.control_id(component, scope), value: renderer.text(component["value"], scope) }
|
|
302
|
+
attributes[:name] = renderer.input_name(path, scope) if path
|
|
303
|
+
placeholder = renderer.text(component["placeholder"], scope)
|
|
304
|
+
attributes[:placeholder] = placeholder unless placeholder.empty?
|
|
305
|
+
attributes.merge!(check_attributes(component))
|
|
306
|
+
error = renderer.error_for(component, scope)
|
|
307
|
+
attributes[:invalid] = true if error
|
|
308
|
+
keyed(component, scope, renderer, attributes)
|
|
309
|
+
control = if component["variant"] == "longText"
|
|
310
|
+
renderer.component(Poetry::Ui::Textarea::Component, attributes.except(:type).merge(rows: 3))
|
|
311
|
+
else
|
|
312
|
+
attributes[:type] ||= { "number" => :number, "obscured" => :password }.fetch(
|
|
313
|
+
component["variant"], :text
|
|
314
|
+
)
|
|
315
|
+
renderer.component(Poetry::Ui::Input::Component, attributes)
|
|
316
|
+
end
|
|
317
|
+
field = { id: attributes[:id], label_text: renderer.text(component["label"], scope) }
|
|
318
|
+
field[:required] = true if attributes[:required]
|
|
319
|
+
field[:invalid] = true if error
|
|
320
|
+
with_error(component, scope, renderer, renderer.component(Poetry::Ui::Field::Component, field) { control })
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def render_check_box(component, scope, renderer)
|
|
324
|
+
path = binding_path(component["value"])
|
|
325
|
+
attributes = { label: renderer.text(component["label"], scope), value: "true", unchecked_value: "false",
|
|
326
|
+
checked: renderer.resolve(component["value"], scope) == true }
|
|
327
|
+
attributes[:name] = renderer.input_name(path, scope) if path
|
|
328
|
+
attributes[:required] = true if check_attributes(component)[:required]
|
|
329
|
+
keyed(component, scope, renderer, attributes)
|
|
330
|
+
with_error(component, scope, renderer, renderer.component(Poetry::Ui::Checkbox::Component, attributes))
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# `filterable` picks through a Combobox (single or multiple); chips
|
|
334
|
+
# lay the choices out as a wrapping row; the default is a radio
|
|
335
|
+
# group or a checkbox list.
|
|
336
|
+
# `filterable` picks through a Combobox (single or multiple); chips
|
|
337
|
+
# lay the choices out as a wrapping row; the default is a radio
|
|
338
|
+
# group or a checkbox list.
|
|
339
|
+
def render_choice_picker(component, scope, renderer)
|
|
340
|
+
choice = Choice.new(options: Array(component["options"]).grep(Hash), path: binding_path(component["value"]),
|
|
341
|
+
selected: Array(renderer.resolve(component["value"], scope)).map(&:to_s),
|
|
342
|
+
label: renderer.text(component["label"], scope))
|
|
343
|
+
multiple = component["variant"] == "multipleSelection"
|
|
344
|
+
chips = component["displayStyle"] == "chips"
|
|
345
|
+
control = if component["filterable"] == true
|
|
346
|
+
choice_combobox(choice, multiple, scope, renderer)
|
|
347
|
+
elsif multiple
|
|
348
|
+
choice_boxes(choice, chips, scope, renderer)
|
|
349
|
+
else
|
|
350
|
+
attributes = keyed(component, scope, renderer,
|
|
351
|
+
{ label: choice.label, value: choice.selected.first })
|
|
352
|
+
attributes[:orientation] = :horizontal if chips
|
|
353
|
+
attributes[:name] = renderer.input_name(choice.path, scope) if choice.path
|
|
354
|
+
renderer.component(Poetry::Ui::RadioGroup::Component, attributes) do |group|
|
|
355
|
+
choice.options.each do |option|
|
|
356
|
+
group.with_item(label: renderer.text(option["label"], scope), value: option["value"].to_s)
|
|
357
|
+
end
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
with_error(component, scope, renderer, control)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def choice_combobox(choice, multiple, scope, renderer)
|
|
365
|
+
attributes = { multiple: multiple, placeholder: choice.label, aria: { label: choice.label },
|
|
366
|
+
value: multiple ? choice.selected : choice.selected.first }
|
|
367
|
+
attributes[:name] = renderer.input_name(choice.path, scope) if choice.path
|
|
368
|
+
renderer.component(Poetry::Ui::Combobox::Component, attributes) do |combobox|
|
|
369
|
+
choice.options.each do |option|
|
|
370
|
+
combobox.with_item(value: option["value"].to_s) { renderer.text(option["label"], scope) }
|
|
371
|
+
end
|
|
372
|
+
nil
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# A checkbox per option under one list name; the leading empty
|
|
377
|
+
# value keeps "nothing checked" submittable. Chips wrap the row.
|
|
378
|
+
def choice_boxes(choice, chips, scope, renderer)
|
|
379
|
+
name = choice.path && "#{renderer.input_name(choice.path, scope)}[]"
|
|
380
|
+
items = [renderer.view.tag.legend(choice.label, class: "mb-2 text-sm font-medium")]
|
|
381
|
+
items << renderer.view.hidden_field_tag(name, "", id: nil) if name
|
|
382
|
+
choice.options.each do |option|
|
|
383
|
+
value = option["value"].to_s
|
|
384
|
+
attributes = { label: renderer.text(option["label"], scope), value: value,
|
|
385
|
+
checked: choice.selected.include?(value) }
|
|
386
|
+
attributes[:name] = name if name
|
|
387
|
+
box = renderer.component(Poetry::Ui::Checkbox::Component, attributes, suffix: value)
|
|
388
|
+
items << (chips ? renderer.view.tag.span(box, class: "rounded-full border px-3 py-1") : box)
|
|
389
|
+
end
|
|
390
|
+
classes = chips ? "flex flex-row flex-wrap items-center gap-2 [&>legend]:w-full" : "flex flex-col gap-2"
|
|
391
|
+
renderer.view.tag.fieldset(renderer.view.safe_join(items), class: classes)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def render_slider(component, scope, renderer)
|
|
395
|
+
path = binding_path(component["value"])
|
|
396
|
+
min = component["min"].is_a?(Numeric) ? component["min"] : 0
|
|
397
|
+
max = component["max"].is_a?(Numeric) ? component["max"] : 100
|
|
398
|
+
value = renderer.resolve(component["value"], scope)
|
|
399
|
+
label = renderer.text(component["label"], scope)
|
|
400
|
+
label = renderer.aria_label(component, scope) || component["id"].to_s.tr("_", " ") if label.empty?
|
|
401
|
+
attributes = { label: label, min: min, max: max, value: value.is_a?(Numeric) ? value : min }
|
|
402
|
+
attributes[:name] = renderer.input_name(path, scope) if path
|
|
403
|
+
steps = component["steps"]
|
|
404
|
+
attributes[:step] = step_size(min, max, steps) if steps.is_a?(Integer) && steps.positive?
|
|
405
|
+
keyed(component, scope, renderer, attributes)
|
|
406
|
+
with_error(component, scope, renderer, renderer.component(Poetry::Ui::Slider::Component, attributes))
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def step_size(min, max, steps)
|
|
410
|
+
size = (max - min).to_f / steps
|
|
411
|
+
size == size.floor ? size.to_i : size
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def render_date_time_input(component, scope, renderer)
|
|
415
|
+
path = binding_path(component["value"])
|
|
416
|
+
type = if component["enableTime"] && component["enableDate"] then "datetime-local"
|
|
417
|
+
elsif component["enableTime"] then "time"
|
|
418
|
+
else "date"
|
|
419
|
+
end
|
|
420
|
+
attributes = { id: renderer.control_id(component, scope), type: type,
|
|
421
|
+
value: renderer.text(component["value"], scope) }
|
|
422
|
+
attributes[:name] = renderer.input_name(path, scope) if path
|
|
423
|
+
%w[min max].each do |bound|
|
|
424
|
+
limit = renderer.text(component[bound], scope)
|
|
425
|
+
attributes[bound.to_sym] = limit unless limit.empty?
|
|
426
|
+
end
|
|
427
|
+
attributes[:required] = true if check_attributes(component)[:required]
|
|
428
|
+
keyed(component, scope, renderer, attributes)
|
|
429
|
+
with_error(component, scope, renderer, renderer.component(Poetry::Ui::Input::Component, attributes))
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# The checks the browser can enforce as constraint attributes.
|
|
433
|
+
def check_attributes(component)
|
|
434
|
+
attributes = {}
|
|
435
|
+
Array(component["checks"]).each do |check|
|
|
436
|
+
condition = check.is_a?(Hash) ? check["condition"] : nil
|
|
437
|
+
next unless condition.is_a?(Hash) && condition["call"]
|
|
438
|
+
|
|
439
|
+
args = condition["args"].is_a?(Hash) ? condition["args"] : {}
|
|
440
|
+
case condition["call"]
|
|
441
|
+
when "required" then attributes[:required] = true
|
|
442
|
+
when "regex" then attributes[:pattern] = args["pattern"] if args["pattern"].is_a?(String)
|
|
443
|
+
when "length"
|
|
444
|
+
attributes[:minlength] = args["min"] if args["min"].is_a?(Integer)
|
|
445
|
+
attributes[:maxlength] = args["max"] if args["max"].is_a?(Integer)
|
|
446
|
+
when "email" then attributes[:type] = :email
|
|
447
|
+
when "numeric" then attributes[:inputmode] = "decimal"
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
attributes
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def binding_path(value)
|
|
454
|
+
value.is_a?(Hash) && value["path"].is_a?(String) ? value["path"] : nil
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../pointer"
|
|
4
|
+
require_relative "../catalog"
|
|
5
|
+
|
|
6
|
+
module Poetry
|
|
7
|
+
module Agent
|
|
8
|
+
module A2UI
|
|
9
|
+
module Catalogs
|
|
10
|
+
# Poetry's own catalog (the {Catalog} projection) rendered back
|
|
11
|
+
# through the registry: a component name resolves to its registry
|
|
12
|
+
# entry, style axes and options become constructor keywords, slot
|
|
13
|
+
# properties drive the generated slot setters, `text` is the
|
|
14
|
+
# content block, `children` and `child` nest, and a bound `value`
|
|
15
|
+
# names the control for the surface form. The registry is the one
|
|
16
|
+
# source: whatever it declares renders, nothing is hand-mapped.
|
|
17
|
+
class Native
|
|
18
|
+
# The bound options an input component may carry.
|
|
19
|
+
BOUND_OPTIONS = %w[value checked values].freeze
|
|
20
|
+
# Registry option types by bound kind.
|
|
21
|
+
KINDS = { "boolean" => :boolean, "checked_state" => :boolean, "integer" => :number, "number" => :number,
|
|
22
|
+
"float" => :number, "list" => :string_list }.freeze
|
|
23
|
+
|
|
24
|
+
# @return [String]
|
|
25
|
+
attr_reader :id
|
|
26
|
+
|
|
27
|
+
# @param entries [Hash{String => Hash}, nil] registry entries by path
|
|
28
|
+
# (the poetry-ui registry when nil)
|
|
29
|
+
# @param id [String] the catalog id this binding answers to
|
|
30
|
+
def initialize(entries: nil, id: Catalog::DEFAULT_ID)
|
|
31
|
+
@entries = entries
|
|
32
|
+
@id = id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Functions] the function set Poetry's catalog declares (the basic one)
|
|
36
|
+
def functions
|
|
37
|
+
Functions.basic
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash{String => Hash}] registry entries by path
|
|
41
|
+
def entries
|
|
42
|
+
@entries ||= Catalog.load_entries
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Hash{String => Array(String, Hash)}] `[path, entry]` by component name
|
|
46
|
+
def by_name
|
|
47
|
+
@by_name ||= entries.to_h { |path, entry| [Catalog.component_name(path), [path, entry]] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The child references: `child`, `children`, and every slot property.
|
|
51
|
+
#
|
|
52
|
+
# @param component [Hash]
|
|
53
|
+
# @return [Array<Object>]
|
|
54
|
+
def references(component)
|
|
55
|
+
_path, entry = lookup(component)
|
|
56
|
+
references = [component["child"], component["children"]]
|
|
57
|
+
Array(entry && entry["slots"]).each { |slot| references << component[slot["name"]] }
|
|
58
|
+
references.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param component [Hash]
|
|
62
|
+
# @param scope [String, nil]
|
|
63
|
+
# @return [Array<Hash>] `{ path:, kind: }` for each bound option
|
|
64
|
+
def inputs(component, scope)
|
|
65
|
+
_path, entry = lookup(component)
|
|
66
|
+
return [] unless entry
|
|
67
|
+
|
|
68
|
+
BOUND_OPTIONS.filter_map do |name|
|
|
69
|
+
value = component[name]
|
|
70
|
+
next unless value.is_a?(Hash) && value["path"].is_a?(String)
|
|
71
|
+
|
|
72
|
+
option = Array(entry["options"]).find { |candidate| candidate["name"] == name }
|
|
73
|
+
next unless option
|
|
74
|
+
|
|
75
|
+
{ path: Pointer.absolute(value["path"], scope), kind: KINDS.fetch(option["type"].to_s, :string) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param component [Hash]
|
|
80
|
+
# @param scope [String, nil]
|
|
81
|
+
# @param renderer [Renderer]
|
|
82
|
+
# @return [String] HTML
|
|
83
|
+
def render(component, scope, renderer)
|
|
84
|
+
_path, entry = lookup(component)
|
|
85
|
+
return renderer.warn("unknown component #{component["component"].inspect}") unless entry
|
|
86
|
+
|
|
87
|
+
klass = Object.const_get(entry["class_name"])
|
|
88
|
+
renderer.component(klass, attributes(component, entry, scope, renderer)) do |instance|
|
|
89
|
+
fill_slots(instance, component, entry, scope, renderer)
|
|
90
|
+
content(component, scope, renderer)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def lookup(component)
|
|
97
|
+
by_name[component["component"].to_s] || [nil, nil]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def attributes(component, entry, scope, renderer)
|
|
101
|
+
attributes = {}
|
|
102
|
+
Array(entry["styles"]).each do |axis|
|
|
103
|
+
value = component[axis["name"]]
|
|
104
|
+
attributes[axis["name"].to_sym] = value.to_sym if value.is_a?(String)
|
|
105
|
+
end
|
|
106
|
+
option_names = Array(entry["options"]).map { |option| option["name"] }
|
|
107
|
+
option_names.each do |name|
|
|
108
|
+
next if Catalog.skipped_option?(name) || !component.key?(name)
|
|
109
|
+
|
|
110
|
+
attributes[name.to_sym] = renderer.resolve(component[name], scope)
|
|
111
|
+
end
|
|
112
|
+
bound = BOUND_OPTIONS.find { |name| renderer.surface.binding?(component[name]) }
|
|
113
|
+
if bound && option_names.include?("name") && !component.key?("name")
|
|
114
|
+
attributes[:name] = renderer.input_name(component[bound]["path"], scope)
|
|
115
|
+
end
|
|
116
|
+
label = renderer.aria_label(component, scope)
|
|
117
|
+
attributes[:aria] = { label: label } if label
|
|
118
|
+
attributes.merge!(action_attributes(component, scope, renderer)) if component["action"].is_a?(Hash)
|
|
119
|
+
if bound || component["action"]
|
|
120
|
+
attributes[:data] = (attributes[:data] || {}).merge(a2ui_key: renderer.current_key)
|
|
121
|
+
end
|
|
122
|
+
attributes
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def action_attributes(component, scope, renderer)
|
|
126
|
+
action = component["action"]
|
|
127
|
+
return renderer.submit_attributes(component, scope) if action["event"].is_a?(Hash)
|
|
128
|
+
|
|
129
|
+
call = action["functionCall"]
|
|
130
|
+
if call.is_a?(Hash) && call["call"] == "openUrl"
|
|
131
|
+
url = renderer.call_function("openUrl", call["args"], scope)
|
|
132
|
+
return { href: url } if url
|
|
133
|
+
else
|
|
134
|
+
renderer.warn("#{component["component"]} #{component["id"].inspect}: unsupported local action")
|
|
135
|
+
end
|
|
136
|
+
{ type: :button, disabled: true }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A slot property holds one id (renders_one) or ids (renders_many).
|
|
140
|
+
# A positional setter (Tabs' `with_tab(title, value:)`) takes the
|
|
141
|
+
# child's `text` as its title and the child's children as its panel.
|
|
142
|
+
def fill_slots(instance, component, entry, scope, renderer)
|
|
143
|
+
Array(entry["slots"]).each do |slot|
|
|
144
|
+
target = component[slot["name"]]
|
|
145
|
+
next if target.nil?
|
|
146
|
+
|
|
147
|
+
setter = slot["many"] ? "with_#{slot["name"].singularize}" : "with_#{slot["name"]}"
|
|
148
|
+
positional = slot["setter_args"].is_a?(Hash) ? slot["setter_args"].values.first.to_i : 0
|
|
149
|
+
renderer.surface.expand(target, scope).each do |child_id, child_scope|
|
|
150
|
+
if positional.positive?
|
|
151
|
+
child = renderer.surface.component(child_id) || {}
|
|
152
|
+
title = renderer.text(child["text"], child_scope)
|
|
153
|
+
instance.public_send(setter, title, value: child_id) do
|
|
154
|
+
renderer.render_children(child["children"] || child["child"], child_scope)
|
|
155
|
+
end
|
|
156
|
+
else
|
|
157
|
+
instance.public_send(setter) { renderer.render_component(child_id, child_scope) }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def content(component, scope, renderer)
|
|
164
|
+
if component.key?("text")
|
|
165
|
+
renderer.view.safe_join([renderer.text(component["text"], scope)])
|
|
166
|
+
elsif component.key?("children")
|
|
167
|
+
renderer.render_children(component["children"], scope)
|
|
168
|
+
elsif component.key?("child")
|
|
169
|
+
renderer.render_children(component["child"], scope)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|