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,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,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
|