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.
@@ -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