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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ class ComponentTreeValidator
5
+ Result = Data.define(:valid, :errors) do
6
+ alias_method :valid?, :valid
7
+ end
8
+
9
+ TREE_KEY = '_tree'
10
+ MAX_COMPONENTS = 500
11
+ MAX_DEPTH = 64
12
+ MAX_REFS = 2_000
13
+
14
+ def self.call(component_set, catalog:)
15
+ new(component_set, catalog).call
16
+ end
17
+
18
+ def initialize(component_set, catalog)
19
+ @component_set = component_set
20
+ @catalog = catalog
21
+ @errors = {}
22
+ @edges = Hash.new { |hash, key| hash[key] = [] }
23
+ end
24
+
25
+ def call
26
+ validate_admission_limits
27
+ return Result.new(valid: false, errors:) if errors.any?
28
+
29
+ validate_components
30
+ validate_structure
31
+ Result.new(valid: errors.empty?, errors:)
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :component_set, :catalog, :errors, :edges
37
+
38
+ def validate_admission_limits
39
+ tree_error("component limit exceeded (max #{MAX_COMPONENTS})") if component_set.components.size > MAX_COMPONENTS
40
+ end
41
+
42
+ def validate_components
43
+ component_set.components.each do |component|
44
+ result = ComponentValidator.call(component, catalog:)
45
+ result.errors.each { |message| component_error(component.id || '_component', message) } unless result.valid?
46
+ end
47
+ end
48
+
49
+ def validate_structure
50
+ component_set.duplicate_ids.each { |id| tree_error("duplicate component id '#{id}'") }
51
+ return tree_error('root component not found') unless component_set.root
52
+
53
+ collect_edges
54
+ walk_from_root
55
+ report_orphans
56
+ end
57
+
58
+ def collect_edges
59
+ @parents = {}
60
+ @ref_count = 0
61
+
62
+ component_set.components.each do |component|
63
+ definition = catalog.fetch(component.name)
64
+ next unless definition
65
+
66
+ definition.structural_refs.each do |ref|
67
+ extract_refs(component.attributes, ref.path).each do |field_path, child_id|
68
+ @ref_count += 1
69
+ tree_error("reference limit exceeded (max #{MAX_REFS})") if @ref_count == MAX_REFS + 1
70
+ source = "#{component.id}.#{field_path}"
71
+ validate_reference(component, ref, source, child_id)
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ def validate_reference(component, ref, source, child_id)
78
+ target = component_set.by_id[child_id]
79
+ return relation_error(source, "referenced component '#{child_id}' not found") unless target
80
+
81
+ if ref.only.present? && !ref.only.include?(target.name)
82
+ relation_error(source, "expected #{ref.only.join(' or ')}, got #{target.name}")
83
+ end
84
+
85
+ if (existing_parent = @parents[child_id])
86
+ tree_error("component '#{child_id}' referenced by both '#{existing_parent}' and '#{source}'")
87
+ else
88
+ @parents[child_id] = source
89
+ end
90
+
91
+ edges[component.id] << child_id
92
+ end
93
+
94
+ def extract_refs(value, path, rendered_path = [])
95
+ return [] if path.empty?
96
+
97
+ segment, *rest = path
98
+ if segment == :*
99
+ return [] unless value.is_a?(Array)
100
+
101
+ return value.each_with_index.flat_map do |item, index|
102
+ extract_refs(item, rest, rendered_path + [index])
103
+ end
104
+ end
105
+
106
+ return [] unless value.is_a?(Hash)
107
+
108
+ child = value[segment.to_s] || value[segment.to_sym]
109
+ current_path = rendered_path + [segment]
110
+
111
+ if rest.empty?
112
+ return child.map { |child_id| [format_path(current_path), child_id] } if child.is_a?(Array)
113
+
114
+ return child.nil? ? [] : [[format_path(current_path), child]]
115
+ end
116
+
117
+ extract_refs(child, rest, current_path)
118
+ end
119
+
120
+ def walk_from_root
121
+ @visited = Set.new
122
+ @in_progress = []
123
+ walk('root', 1)
124
+ end
125
+
126
+ def walk(id, depth)
127
+ if depth > MAX_DEPTH
128
+ tree_error("tree depth limit exceeded (max #{MAX_DEPTH})")
129
+ return
130
+ end
131
+
132
+ if @in_progress.include?(id)
133
+ cycle_start = @in_progress.index(id)
134
+ path = @in_progress[cycle_start..] + [id]
135
+ tree_error("cycle: #{path.join(' → ')}")
136
+ return
137
+ end
138
+
139
+ return if @visited.include?(id)
140
+
141
+ @in_progress << id
142
+ edges[id].each { |child_id| walk(child_id, depth + 1) }
143
+ @in_progress.pop
144
+ @visited << id
145
+ end
146
+
147
+ def report_orphans
148
+ (component_set.ids.compact.uniq - @visited.to_a).each do |id|
149
+ tree_error("orphan component '#{id}'")
150
+ end
151
+ end
152
+
153
+ def format_path(parts)
154
+ parts.each_with_object(+'') do |part, rendered|
155
+ if part.is_a?(Integer)
156
+ rendered << "[#{part}]"
157
+ else
158
+ rendered << '.' unless rendered.empty?
159
+ rendered << part.to_s
160
+ end
161
+ end
162
+ end
163
+
164
+ def tree_error(message)
165
+ (errors[TREE_KEY] ||= []) << message
166
+ end
167
+
168
+ def component_error(id, message)
169
+ (errors[id] ||= []) << message
170
+ end
171
+
172
+ def relation_error(path, message)
173
+ (errors[path] ||= []) << message
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+
5
+ module GenerativeUI
6
+ class ComponentValidator
7
+ Result = Data.define(:valid, :errors) do
8
+ alias_method :valid?, :valid
9
+ end
10
+
11
+ def self.call(component, catalog:)
12
+ new(component, catalog).call
13
+ end
14
+
15
+ def initialize(component, catalog)
16
+ @component = component
17
+ @catalog = catalog
18
+ end
19
+
20
+ def call
21
+ unless component.name.is_a?(String) && component.name.present?
22
+ return invalid(['component must be a non-empty string'])
23
+ end
24
+ return invalid(["unknown component: #{component.name}"]) unless catalog.fetch(component.name)
25
+
26
+ errors = JSONSchemer.schema(schema).validate(component.to_h).flat_map { |error| format_error(error) }
27
+ Result.new(valid: errors.empty?, errors:)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :component, :catalog
33
+
34
+ def schema
35
+ JSON.parse(catalog.component_schema(component.name).to_json)
36
+ end
37
+
38
+ def invalid(errors)
39
+ Result.new(valid: false, errors:)
40
+ end
41
+
42
+ def format_error(error)
43
+ pointer = error.fetch('data_pointer', '')
44
+ property = pointer.delete_prefix('/').presence
45
+ type = error.fetch('type', 'invalid')
46
+
47
+ if type == 'required'
48
+ missing = error.dig('details', 'missing_keys') || []
49
+ missing.map { |key| "#{[property, key].compact.join('.')} required" }
50
+ elsif type == 'enum'
51
+ expected = error.fetch('schema', {}).fetch('enum', [])
52
+ ["#{property || 'component'} expected one of #{expected.inspect}, got #{error.fetch('data').inspect}"]
53
+ elsif %w[string number integer boolean array object].include?(type)
54
+ ["#{property || 'component'} expected #{type}, got #{value_type(error.fetch('data', nil))}"]
55
+ elsif type == 'schema' && error['schema'] == false
56
+ ["#{property || 'component'} is not allowed"]
57
+ else
58
+ ["#{property || 'component'} #{type}"]
59
+ end
60
+ end
61
+
62
+ def value_type(value)
63
+ case value
64
+ when String then 'string'
65
+ when Integer then 'integer'
66
+ when Numeric then 'number'
67
+ when TrueClass, FalseClass then 'boolean'
68
+ when Array then 'array'
69
+ when Hash
70
+ 'object'
71
+ when NilClass
72
+ 'null'
73
+ else
74
+ value.class.name
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ module Conventions
5
+ @rules = {}
6
+
7
+ class << self
8
+ def register(adapter, &block)
9
+ @rules[adapter.to_sym] = block
10
+ end
11
+
12
+ def fetch(adapter)
13
+ @rules.fetch(adapter.to_sym) do
14
+ raise ArgumentError, "No convention registered for adapter: #{adapter.inspect}"
15
+ end
16
+ end
17
+ end
18
+
19
+ register(:partial) { |name| "generative_ui/#{name.underscore}" }
20
+ register(:view_component) { |name| "GenerativeUI::#{name.camelize}Component" }
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/engine'
4
+
5
+ module GenerativeUI
6
+ class Engine < ::Rails::Engine
7
+ initializer 'generative_ui.active_support_inflections', before: :load_config_initializers do
8
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
9
+ inflect.acronym 'UI'
10
+ end
11
+ end
12
+
13
+ initializer 'generative_ui.inflections', before: :set_autoload_paths do
14
+ next unless defined?(Rails.autoloaders) && Rails.autoloaders.zeitwerk_enabled?
15
+
16
+ Rails.autoloaders.main.inflector.inflect(
17
+ 'generative_ui' => 'GenerativeUI'
18
+ )
19
+ end
20
+
21
+ initializer 'generative_ui.view_helper' do
22
+ ActiveSupport.on_load(:action_view) do
23
+ include GenerativeUI::ViewHelper
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ class InvalidComponentTreeError < StandardError
5
+ attr_reader :errors
6
+
7
+ def initialize(errors)
8
+ @errors = errors
9
+ super("Invalid generative UI component tree: #{errors.inspect}")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ class Renderer
5
+ def initialize(catalog: :default)
6
+ @catalog_spec = catalog
7
+ end
8
+
9
+ def catalog=(value)
10
+ @catalog_spec = value
11
+ @catalog = nil
12
+ end
13
+
14
+ def catalog
15
+ @catalog ||= Catalog.coerce(@catalog_spec)
16
+ end
17
+
18
+ def call(component_set)
19
+ render_component_instance(component_set, component_set.root)
20
+ end
21
+
22
+ def render_component_instance(component_set, component)
23
+ definition = catalog.fetch(component.name)
24
+ raise ArgumentError, "Unknown generative component: #{component.name}" unless definition
25
+
26
+ attributes, additional_properties = materialized_attributes(component_set, component, definition)
27
+ render_component(definition:, attributes:, additional_properties:)
28
+ end
29
+
30
+ def render_component(definition:, attributes:, additional_properties:)
31
+ raise NotImplementedError, "#{self.class} must implement #render_component"
32
+ end
33
+
34
+ private
35
+
36
+ def materialized_attributes(component_set, component, definition)
37
+ props = deep_dup(component.attributes)
38
+ Array(definition&.structural_refs).each do |ref|
39
+ replace_ref!(props, ref.path) do |child_id|
40
+ render_component_instance(component_set, component_set.fetch(child_id))
41
+ end
42
+ end
43
+ split_declared_attributes(underscore_string_keys(props), definition)
44
+ end
45
+
46
+ def split_declared_attributes(props, definition)
47
+ declared_names = declared_attribute_names(definition)
48
+ attributes = {}
49
+ additional_properties = {}
50
+
51
+ props.each do |key, value|
52
+ if declared_names.include?(key)
53
+ attributes[key.to_sym] = deep_symbolize_keys(value)
54
+ else
55
+ additional_properties[key] = value
56
+ end
57
+ end
58
+
59
+ [attributes, additional_properties_enabled?(definition) ? additional_properties : nil]
60
+ end
61
+
62
+ def declared_attribute_names(definition)
63
+ schema = definition&.attributes_json_schema || {}
64
+ properties = schema[:properties] || schema['properties'] || {}
65
+ properties.keys.map { |key| key.to_s.underscore }
66
+ end
67
+
68
+ def additional_properties_enabled?(definition)
69
+ schema = definition&.attributes_json_schema || {}
70
+ schema[:additionalProperties] || schema['additionalProperties']
71
+ end
72
+
73
+ def replace_ref!(value, path, &block)
74
+ return if value.nil?
75
+
76
+ segment, *rest = path
77
+ if segment == :*
78
+ Array(value).each { |item| replace_ref!(item, rest, &block) }
79
+ return
80
+ end
81
+
82
+ key = matching_key(value, segment)
83
+ return unless key
84
+
85
+ if rest.empty?
86
+ child = value[key]
87
+ value[key] =
88
+ if child.is_a?(Array)
89
+ child.map(&block)
90
+ else
91
+ block.call(child)
92
+ end
93
+ else
94
+ replace_ref!(value[key], rest, &block)
95
+ end
96
+ end
97
+
98
+ def deep_dup(value)
99
+ case value
100
+ when Hash then value.transform_values { |child| deep_dup(child) }
101
+ when Array then value.map { |child| deep_dup(child) }
102
+ else value
103
+ end
104
+ end
105
+
106
+ def underscore_string_keys(value)
107
+ case value
108
+ when Hash
109
+ value.each_with_object({}) do |(key, child), transformed|
110
+ normalized = key.is_a?(String) ? key.underscore : key
111
+ transformed[normalized] = underscore_string_keys(child)
112
+ end
113
+ when Array
114
+ value.map { |child| underscore_string_keys(child) }
115
+ else
116
+ value
117
+ end
118
+ end
119
+
120
+ def matching_key(hash, segment)
121
+ return unless hash.is_a?(Hash)
122
+
123
+ [segment.to_s, segment.to_sym].find { |candidate| hash.key?(candidate) }
124
+ end
125
+
126
+ def deep_symbolize_keys(value)
127
+ case value
128
+ when Hash
129
+ value.each_with_object({}) do |(key, child), transformed|
130
+ transformed[key.to_sym] = deep_symbolize_keys(child)
131
+ end
132
+ when Array
133
+ value.map { |child| deep_symbolize_keys(child) }
134
+ else
135
+ value
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ module Renderers
5
+ class Json < Renderer
6
+ def initialize(catalog: :default, mode: :materialized)
7
+ super(catalog:)
8
+ @mode = mode
9
+ end
10
+
11
+ def call(component_set)
12
+ return { 'components' => component_set.components.map(&:to_h) } if @mode == :flat
13
+ raise ArgumentError, "Unknown JSON render mode: #{@mode}" unless @mode == :materialized
14
+
15
+ super
16
+ end
17
+
18
+ def render_component(definition:, attributes:, additional_properties:)
19
+ props = attributes.dup
20
+ props.merge!(additional_properties) if additional_properties
21
+
22
+ { 'component' => definition.component, 'props' => props }.deep_stringify_keys
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ module Renderers
5
+ class Partial < Renderer
6
+ ADAPTER = :partial
7
+
8
+ def initialize(view_context:, catalog: :default)
9
+ super(catalog:)
10
+ @view_context = view_context
11
+ end
12
+
13
+ def render_component(definition:, attributes:, additional_properties:)
14
+ locals = attributes.dup
15
+ locals[:additional_properties] = additional_properties unless additional_properties.nil?
16
+
17
+ @view_context.render(
18
+ partial: catalog.target_for(definition, ADAPTER),
19
+ locals:
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ module Renderers
5
+ class ViewComponent < Renderer
6
+ ADAPTER = :view_component
7
+
8
+ def initialize(view_context:, catalog: :default)
9
+ super(catalog:)
10
+ @view_context = view_context
11
+ end
12
+
13
+ def render_component(definition:, attributes:, additional_properties:)
14
+ component_class = resolve(catalog.target_for(definition, ADAPTER))
15
+ kwargs = attributes.dup
16
+ kwargs[:additional_properties] = additional_properties unless additional_properties.nil?
17
+ @view_context.render(component_class.new(**kwargs))
18
+ end
19
+
20
+ private
21
+
22
+ def resolve(target)
23
+ target.is_a?(String) ? target.constantize : target
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ StructuralRef = Data.define(
5
+ :path,
6
+ :cardinality,
7
+ :required,
8
+ :only,
9
+ :description,
10
+ :min_items,
11
+ :max_items
12
+ )
13
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+
5
+ module GenerativeUI
6
+ class Tool < RubyLLM::Tool
7
+ attr_reader :catalog
8
+
9
+ def initialize(catalog: :default)
10
+ @catalog = Catalog.coerce(catalog)
11
+ raise ArgumentError, 'generative UI catalog is empty' if @catalog.empty?
12
+ end
13
+
14
+ def name
15
+ 'generate_ui'
16
+ end
17
+
18
+ def description
19
+ <<~TEXT.strip
20
+ Render inline UI from the available component catalog.
21
+
22
+ Call this tool at most once per assistant turn. After it returns
23
+ {"status":"ok"}, the UI is shown to the user — do not call it again.
24
+
25
+ Arguments:
26
+ - components is a flat array of component instances.
27
+ - Each component is an object: { "id": "...", "component": "<Name>", ...attributes }
28
+ where "component" is the literal component name as a string.
29
+ - One component must have id="root".
30
+ - ComponentId fields reference one component id.
31
+ - ComponentIdList fields reference ordered arrays of component ids.
32
+ - The accepted payload must form one rooted tree.
33
+
34
+ Example payload (component names below must be from the catalog;
35
+ ids are arbitrary labels — suffix them with numbers so they are
36
+ clearly distinct from component names):
37
+ {
38
+ "components": [
39
+ { "id": "root", "component": "<CatalogComponent>", ...attributes },
40
+ { "id": "title-1", "component": "<CatalogComponent>", ...attributes },
41
+ { "id": "body-1", "component": "<CatalogComponent>", ...attributes }
42
+ ]
43
+ }
44
+
45
+ #{catalog.to_prompt}
46
+ TEXT
47
+ end
48
+
49
+ def params_schema
50
+ @params_schema ||= JSON.parse(catalog.tool_arguments_schema.to_json)
51
+ end
52
+
53
+ def execute(**args)
54
+ unknown = args.keys.map(&:to_s) - %w[components]
55
+ return invalid_arguments("unknown arguments: #{unknown.join(', ')}") if unknown.any?
56
+
57
+ components = args[:components]
58
+ return invalid_arguments('components must be an array') unless components.is_a?(Array)
59
+
60
+ set = ComponentSet.from_args(components)
61
+ validation = ComponentTreeValidator.call(set, catalog:)
62
+
63
+ if validation.valid?
64
+ { status: 'ok' }.to_json
65
+ else
66
+ { status: 'invalid', errors: validation.errors }.to_json
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def invalid_arguments(message)
73
+ { status: 'invalid', errors: { '_arguments' => [message] } }.to_json
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ VERSION = '0.0.1'
5
+ end