generative_ui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +15 -0
- data/README.md +370 -0
- data/app/helpers/generative_ui/view_helper.rb +10 -0
- data/app/views/messages/tool_calls/_generate_ui.html.erb +6 -0
- data/app/views/messages/tool_results/_generate_ui.html.erb +1 -0
- data/generative_ui.gemspec +31 -0
- data/lib/generative_ui/attributes.rb +182 -0
- data/lib/generative_ui/catalog.rb +419 -0
- data/lib/generative_ui/component.rb +22 -0
- data/lib/generative_ui/component_definition.rb +73 -0
- data/lib/generative_ui/component_set.rb +37 -0
- data/lib/generative_ui/component_tree_validator.rb +176 -0
- data/lib/generative_ui/component_validator.rb +78 -0
- data/lib/generative_ui/conventions.rb +22 -0
- data/lib/generative_ui/engine.rb +27 -0
- data/lib/generative_ui/invalid_component_tree_error.rb +12 -0
- data/lib/generative_ui/renderer.rb +139 -0
- data/lib/generative_ui/renderers/json.rb +26 -0
- data/lib/generative_ui/renderers/partial.rb +24 -0
- data/lib/generative_ui/renderers/view_component.rb +27 -0
- data/lib/generative_ui/structural_ref.rb +13 -0
- data/lib/generative_ui/tool.rb +76 -0
- data/lib/generative_ui/version.rb +5 -0
- data/lib/generative_ui.rb +121 -0
- metadata +109 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GenerativeUI
|
|
4
|
+
class Catalog
|
|
5
|
+
class InvalidCatalogError < ArgumentError; end
|
|
6
|
+
|
|
7
|
+
REF_DEFS = {
|
|
8
|
+
ComponentId: {
|
|
9
|
+
type: 'string',
|
|
10
|
+
description: 'Reference to another component id in this UI tree.'
|
|
11
|
+
},
|
|
12
|
+
ComponentIdList: {
|
|
13
|
+
type: 'array',
|
|
14
|
+
description: 'Ordered list of referenced component ids.',
|
|
15
|
+
items: { "$ref": '#/$defs/ComponentId' }
|
|
16
|
+
}
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :definitions
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def component(name, &block)
|
|
23
|
+
definition = ComponentDefinition.build(name, &block)
|
|
24
|
+
|
|
25
|
+
if component_definitions.any? { |existing| existing.component == definition.component }
|
|
26
|
+
warn "GenerativeUI: component #{definition.component.inspect} was already declared; replacing previous declaration"
|
|
27
|
+
component_definitions.delete_if { |existing| existing.component == definition.component }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
component_definitions << definition
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def present_with(adapter, target = nil, &block)
|
|
34
|
+
raise ArgumentError, 'present_with at catalog scope requires a block' unless block
|
|
35
|
+
raise ArgumentError, 'present_with at catalog scope does not take a positional target' unless target.nil?
|
|
36
|
+
|
|
37
|
+
default_targets[adapter.to_sym] = block
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def component_definitions
|
|
41
|
+
@component_definitions ||= []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default_targets
|
|
45
|
+
@default_targets ||= {}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.coerce(value)
|
|
50
|
+
case value
|
|
51
|
+
when Catalog
|
|
52
|
+
value
|
|
53
|
+
when Class
|
|
54
|
+
raise ArgumentError, "expected Catalog subclass, got #{value}" unless value <= Catalog
|
|
55
|
+
|
|
56
|
+
value.new
|
|
57
|
+
when Symbol, String
|
|
58
|
+
name = value.to_sym
|
|
59
|
+
configured = GenerativeUI.configuration.catalog(name)
|
|
60
|
+
return instantiate(configured) if configured
|
|
61
|
+
|
|
62
|
+
if name == :default
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
'Default generative UI catalog is not configured. Configure ' \
|
|
65
|
+
'`config.catalog :default, "ApplicationGenerativeCatalog"` in an initializer.'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
raise ArgumentError, "Unknown generative UI catalog: #{name.inspect}"
|
|
69
|
+
else
|
|
70
|
+
raise ArgumentError, 'expected Catalog, catalog class, or catalog name'
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.instantiate(value)
|
|
75
|
+
case value
|
|
76
|
+
when Catalog then value
|
|
77
|
+
when Class then value.new
|
|
78
|
+
when String then value.constantize.new
|
|
79
|
+
else
|
|
80
|
+
raise ArgumentError, "cannot instantiate catalog from #{value.inspect}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initialize
|
|
85
|
+
@definitions = self.class.component_definitions.dup
|
|
86
|
+
validate!
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def names
|
|
90
|
+
definitions.map(&:component).sort
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def empty?
|
|
94
|
+
definitions.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fetch(component)
|
|
98
|
+
definitions.find { |definition| definition.component == component.to_s }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_prompt_entries
|
|
102
|
+
definitions.sort_by(&:component).map do |definition|
|
|
103
|
+
schema = definition.attributes_json_schema
|
|
104
|
+
required = Array(schema[:required] || schema['required']).map(&:to_s)
|
|
105
|
+
properties = schema[:properties] || schema['properties'] || {}
|
|
106
|
+
|
|
107
|
+
props = properties.flat_map do |name, property_schema|
|
|
108
|
+
prompt_properties(name.to_s, property_schema).map { |prop| [name.to_s, prop] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
component: definition.component,
|
|
113
|
+
description: definition.description_text,
|
|
114
|
+
required: props.select { |name, _| required.include?(name) }.map(&:last),
|
|
115
|
+
optional: props.reject { |name, _| required.include?(name) }.map(&:last)
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def to_prompt
|
|
121
|
+
lines = ['Components:']
|
|
122
|
+
|
|
123
|
+
to_prompt_entries.each do |entry|
|
|
124
|
+
description = entry.fetch(:description)
|
|
125
|
+
lines << (description.nil? ? "- #{entry.fetch(:component)}" : "- #{entry.fetch(:component)}: #{description}")
|
|
126
|
+
|
|
127
|
+
if entry.fetch(:required).empty? && entry.fetch(:optional).empty?
|
|
128
|
+
lines << ' attributes: none'
|
|
129
|
+
else
|
|
130
|
+
lines << " required: #{entry.fetch(:required).join(', ')}" if entry.fetch(:required).any?
|
|
131
|
+
lines << " optional: #{entry.fetch(:optional).join(', ')}" if entry.fetch(:optional).any?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
lines.join("\n")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def component_schema(component_name)
|
|
139
|
+
definition = fetch(component_name)
|
|
140
|
+
raise ArgumentError, "Unknown generative component: #{component_name}" unless definition
|
|
141
|
+
|
|
142
|
+
attributes_schema = definition.attributes_json_schema
|
|
143
|
+
properties = attributes_schema.fetch(:properties, {}).transform_values do |schema|
|
|
144
|
+
compile_provider_schema(schema)
|
|
145
|
+
end
|
|
146
|
+
required = Array(attributes_schema[:required]).map(&:to_sym)
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
type: 'object',
|
|
150
|
+
"$defs": REF_DEFS,
|
|
151
|
+
properties: {
|
|
152
|
+
id: { type: 'string' },
|
|
153
|
+
component: { const: definition.component },
|
|
154
|
+
**properties
|
|
155
|
+
},
|
|
156
|
+
required: [:id, :component, *required],
|
|
157
|
+
additionalProperties: attributes_schema.fetch(:additionalProperties, false)
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def tool_arguments_schema
|
|
162
|
+
{
|
|
163
|
+
type: 'object',
|
|
164
|
+
"$defs": REF_DEFS,
|
|
165
|
+
properties: {
|
|
166
|
+
components: {
|
|
167
|
+
type: 'array',
|
|
168
|
+
items: { anyOf: names.map { |name| component_schema(name) } }
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
required: [:components],
|
|
172
|
+
additionalProperties: false
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
COMPONENT_NAME_REGEX = /\A[A-Z][a-zA-Z0-9]*\z/
|
|
177
|
+
PROPERTY_NAME_REGEX = /\A[a-z][a-zA-Z0-9_]*\z/
|
|
178
|
+
ACRONYM_RUN_REGEX = /[A-Z]{2,}/
|
|
179
|
+
UNDERSCORE_CAP_REGEX = /_[A-Z]/
|
|
180
|
+
|
|
181
|
+
def target_for(definition, adapter)
|
|
182
|
+
adapter = adapter.to_sym
|
|
183
|
+
name = definition.respond_to?(:component) ? definition.component : definition.name
|
|
184
|
+
|
|
185
|
+
definition.render_target_for(adapter) \
|
|
186
|
+
|| resolve_catalog_default(adapter, name) \
|
|
187
|
+
|| Conventions.fetch(adapter).call(name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def resolve_catalog_default(adapter, name)
|
|
193
|
+
block = self.class.default_targets[adapter]
|
|
194
|
+
return nil unless block
|
|
195
|
+
|
|
196
|
+
block.call(name)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate!
|
|
200
|
+
validate_component_names!
|
|
201
|
+
validate_duplicate_components!
|
|
202
|
+
validate_component_fields!
|
|
203
|
+
validate_structural_refs!
|
|
204
|
+
validate_only_targets!
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def validate_component_names!
|
|
208
|
+
definitions.each do |definition|
|
|
209
|
+
next if definition.component.to_s.match?(COMPONENT_NAME_REGEX)
|
|
210
|
+
|
|
211
|
+
raise InvalidCatalogError,
|
|
212
|
+
"invalid component name #{definition.component.inspect}: " \
|
|
213
|
+
'use PascalCase (`Text`, `TabPanel`, `URLInput`); ' \
|
|
214
|
+
'must start with uppercase, ASCII alphanumeric only'
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def validate_duplicate_components!
|
|
219
|
+
duplicates = definitions.group_by(&:component).select { |_name, entries| entries.size > 1 }.keys
|
|
220
|
+
return if duplicates.empty?
|
|
221
|
+
|
|
222
|
+
raise InvalidCatalogError, "duplicate component: #{duplicates.first}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_only_targets!
|
|
226
|
+
available = names
|
|
227
|
+
|
|
228
|
+
definitions.each do |definition|
|
|
229
|
+
definition.structural_refs.each do |ref|
|
|
230
|
+
Array(ref.only).each do |target|
|
|
231
|
+
next if available.include?(target)
|
|
232
|
+
|
|
233
|
+
path = ref.path.reject { |segment| segment == :* }.join('.')
|
|
234
|
+
raise InvalidCatalogError, "#{definition.component}.#{path} only references missing component #{target}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_component_fields!
|
|
241
|
+
definitions.each do |definition|
|
|
242
|
+
raw_schema = definition.attributes_definition&.schema_class&.new&.to_json_schema&.fetch(:schema)
|
|
243
|
+
next unless raw_schema
|
|
244
|
+
|
|
245
|
+
validate_schema_fields!(definition, raw_schema)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def valid_property_name?(name)
|
|
250
|
+
s = name.to_s
|
|
251
|
+
s.match?(PROPERTY_NAME_REGEX) &&
|
|
252
|
+
!s.match?(ACRONYM_RUN_REGEX) &&
|
|
253
|
+
!s.match?(UNDERSCORE_CAP_REGEX)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_schema_fields!(definition, schema, path = [])
|
|
257
|
+
properties = schema[:properties] || schema['properties'] || {}
|
|
258
|
+
|
|
259
|
+
properties.each_key do |name|
|
|
260
|
+
next if valid_property_name?(name)
|
|
261
|
+
|
|
262
|
+
field = (path + [name.to_s]).join('.')
|
|
263
|
+
raise InvalidCatalogError,
|
|
264
|
+
"#{definition.component}.#{field} uses unsupported name '#{name}': " \
|
|
265
|
+
'use snake_case (`tab_items`) or lowerCamelCase (`tabItems`); ' \
|
|
266
|
+
'no leading uppercase, no consecutive uppercase letters, ' \
|
|
267
|
+
'no underscore followed by uppercase'
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
camelized = properties.keys.map { |name| name.to_s.camelize(:lower) }
|
|
271
|
+
|
|
272
|
+
if path.empty? && (reserved = camelized.find { |name| %w[id component].include?(name) })
|
|
273
|
+
field = (path + [reserved]).join('.')
|
|
274
|
+
raise InvalidCatalogError, "#{definition.component}.#{field} uses reserved field #{reserved}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
if (duplicate = camelized.group_by(&:itself).find { |_name, names| names.size > 1 }&.first)
|
|
278
|
+
field = (path + [duplicate]).join('.')
|
|
279
|
+
raise InvalidCatalogError, "#{definition.component} has duplicate field #{field}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
properties.each do |name, child|
|
|
283
|
+
camelized_name = name.to_s.camelize(:lower)
|
|
284
|
+
validate_schema_fields!(definition, child, path + [camelized_name]) if child.is_a?(Hash)
|
|
285
|
+
|
|
286
|
+
items = child[:items] || child['items'] if child.is_a?(Hash)
|
|
287
|
+
validate_schema_fields!(definition, items, path + [camelized_name, '*']) if items.is_a?(Hash)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def validate_structural_refs!
|
|
292
|
+
definitions.each do |definition|
|
|
293
|
+
definition.structural_refs.each do |ref|
|
|
294
|
+
path = ref.path.reject { |segment| segment == :* }.join('.')
|
|
295
|
+
|
|
296
|
+
raise InvalidCatalogError, "#{definition.component}.#{path} only must not be empty" if ref.only == []
|
|
297
|
+
|
|
298
|
+
if ref.cardinality == :many && ref.min_items && ref.max_items && ref.min_items > ref.max_items
|
|
299
|
+
raise InvalidCatalogError, "#{definition.component}.#{path} min_items must be <= max_items"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def compile_provider_schema(value)
|
|
306
|
+
case value
|
|
307
|
+
when Hash
|
|
308
|
+
if (metadata = value[Attributes::METADATA_KEY])
|
|
309
|
+
return compile_structural_ref(value, metadata)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
value.each_with_object({}) do |(key, child), sanitized|
|
|
313
|
+
next if key == Attributes::METADATA_KEY
|
|
314
|
+
|
|
315
|
+
sanitized[key] = compile_provider_schema(child)
|
|
316
|
+
end
|
|
317
|
+
when Array
|
|
318
|
+
value.map { |child| compile_provider_schema(child) }
|
|
319
|
+
else
|
|
320
|
+
value
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def compile_structural_ref(schema, metadata)
|
|
325
|
+
ref =
|
|
326
|
+
if metadata.fetch(:cardinality) == :many
|
|
327
|
+
'#/$defs/ComponentIdList'
|
|
328
|
+
else
|
|
329
|
+
'#/$defs/ComponentId'
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
compiled = {
|
|
333
|
+
allOf: [{ "$ref": ref }],
|
|
334
|
+
description: schema[:description] || schema['description'] || default_ref_description(metadata)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
%i[minItems maxItems].each do |key|
|
|
338
|
+
value = schema[key] || schema[key.to_s]
|
|
339
|
+
compiled[key] = value unless value.nil?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
compiled
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def default_ref_description(metadata)
|
|
346
|
+
allowed = Array(metadata[:only])
|
|
347
|
+
|
|
348
|
+
if metadata.fetch(:cardinality) == :many
|
|
349
|
+
return 'References to child components.' if allowed.empty?
|
|
350
|
+
|
|
351
|
+
"References to #{format_allowed_components(allowed)} components."
|
|
352
|
+
else
|
|
353
|
+
return 'Reference to another component.' if allowed.empty?
|
|
354
|
+
|
|
355
|
+
"Reference to a #{format_allowed_components(allowed)} component."
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def format_allowed_components(allowed)
|
|
360
|
+
return allowed.first if allowed.one?
|
|
361
|
+
|
|
362
|
+
[allowed[0...-1].join(', '), allowed.last].reject(&:blank?).join(' or ')
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def prompt_properties(path, schema)
|
|
366
|
+
schema = schema.transform_keys(&:to_sym)
|
|
367
|
+
structural_ref = schema[Attributes::METADATA_KEY]
|
|
368
|
+
|
|
369
|
+
return [format_prompt_property(path, schema)] if structural_ref
|
|
370
|
+
|
|
371
|
+
properties = schema[:properties] || {}
|
|
372
|
+
items = schema[:items]
|
|
373
|
+
lines = [format_prompt_property(path, schema)]
|
|
374
|
+
|
|
375
|
+
if schema[:type] == 'array' && items
|
|
376
|
+
item_properties = items[:properties] || items['properties'] || {}
|
|
377
|
+
item_properties.each do |name, child|
|
|
378
|
+
lines.concat(prompt_properties("#{path}[].#{name}", child))
|
|
379
|
+
end
|
|
380
|
+
elsif schema[:type] == 'object'
|
|
381
|
+
properties.each do |name, child|
|
|
382
|
+
lines.concat(prompt_properties("#{path}.#{name}", child))
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
lines
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def format_prompt_property(name, schema)
|
|
390
|
+
schema = schema.transform_keys(&:to_sym)
|
|
391
|
+
structural_ref = schema[Attributes::METADATA_KEY]
|
|
392
|
+
type =
|
|
393
|
+
if structural_ref
|
|
394
|
+
allowed = Array(structural_ref[:only]).presence&.join('|')
|
|
395
|
+
if structural_ref[:cardinality] == :many
|
|
396
|
+
allowed ? "components[#{allowed}][]" : 'components[]'
|
|
397
|
+
else
|
|
398
|
+
allowed ? "component[#{allowed}]" : 'component'
|
|
399
|
+
end
|
|
400
|
+
elsif schema[:type] == 'array'
|
|
401
|
+
item_type = schema.dig(:items, :type) || schema.dig(:items, 'type')
|
|
402
|
+
item_type == 'object' ? 'array<object>' : 'array'
|
|
403
|
+
elsif (variants = schema[:anyOf] || schema[:oneOf])
|
|
404
|
+
variants.map { |variant| prompt_schema_type(variant) }.compact.join('|')
|
|
405
|
+
else
|
|
406
|
+
schema[:type]
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
parts = ["#{name}:#{type}"]
|
|
410
|
+
parts << "enum[#{schema[:enum].join(',')}]" if schema[:enum]
|
|
411
|
+
parts.join(' ')
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def prompt_schema_type(schema)
|
|
415
|
+
schema = schema.transform_keys(&:to_sym)
|
|
416
|
+
schema[:type]
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GenerativeUI
|
|
4
|
+
Component = Data.define(:id, :name, :attributes) do
|
|
5
|
+
def self.from_raw(raw)
|
|
6
|
+
raw = {} unless raw.is_a?(Hash)
|
|
7
|
+
id = raw["id"] || raw[:id]
|
|
8
|
+
name = raw["component"] || raw[:component]
|
|
9
|
+
attributes = raw.each_with_object({}) do |(key, value), memo|
|
|
10
|
+
next if %w[id component].include?(key.to_s)
|
|
11
|
+
|
|
12
|
+
memo[key.to_s] = value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
new(id:, name:, attributes:)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{ "id" => id, "component" => name, **attributes }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm/schema'
|
|
4
|
+
|
|
5
|
+
module GenerativeUI
|
|
6
|
+
class ComponentDefinition
|
|
7
|
+
attr_reader :name, :description_text, :attributes_definition
|
|
8
|
+
|
|
9
|
+
def self.build(name, &block)
|
|
10
|
+
definition = new(name)
|
|
11
|
+
definition.instance_eval(&block) if block
|
|
12
|
+
definition
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(name)
|
|
16
|
+
@name = name.to_s
|
|
17
|
+
@description_text = nil
|
|
18
|
+
@attributes_definition = nil
|
|
19
|
+
@render_targets = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def component
|
|
23
|
+
@name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def desc(value)
|
|
27
|
+
@description_text = value.to_s
|
|
28
|
+
end
|
|
29
|
+
alias description desc
|
|
30
|
+
|
|
31
|
+
def attributes(&block)
|
|
32
|
+
return @attributes_definition unless block
|
|
33
|
+
raise ArgumentError, "attributes already defined for #{@name}" if @attributes_definition
|
|
34
|
+
|
|
35
|
+
@attributes_definition = Attributes.build(&block)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def present_with(adapter, target)
|
|
39
|
+
adapter = adapter.to_sym
|
|
40
|
+
if @render_targets.key?(adapter)
|
|
41
|
+
raise ArgumentError, "#{@name}: adapter :#{adapter} already declared"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@render_targets[adapter] = target
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def render_target_for(adapter)
|
|
48
|
+
@render_targets[adapter.to_sym]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def structural_refs
|
|
52
|
+
@attributes_definition&.structural_refs || []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def attributes_json_schema
|
|
56
|
+
return empty_attributes_schema if @attributes_definition.nil?
|
|
57
|
+
|
|
58
|
+
@attributes_definition.json_schema
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def empty_attributes_schema
|
|
64
|
+
{
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {},
|
|
67
|
+
required: [],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
strict: true
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GenerativeUI
|
|
4
|
+
class ComponentSet
|
|
5
|
+
attr_reader :components
|
|
6
|
+
|
|
7
|
+
def self.from_args(raw_components)
|
|
8
|
+
new(Array(raw_components).map { |raw| Component.from_raw(raw) })
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(components)
|
|
12
|
+
@components = components
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def by_id
|
|
16
|
+
@by_id ||= components.each_with_object({}) do |component, index|
|
|
17
|
+
index[component.id] ||= component
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def root
|
|
22
|
+
by_id["root"]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch(id)
|
|
26
|
+
by_id.fetch(id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ids
|
|
30
|
+
components.map(&:id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def duplicate_ids
|
|
34
|
+
ids.compact.group_by(&:itself).select { |_id, entries| entries.size > 1 }.keys
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|