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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: df2a6ce2fc73069777780c94ee27b0b5a42d547967e3fc2a10c71b000c8b8dfa
4
+ data.tar.gz: fc79d3abe16ad49b5cca16d3afe605ec9dd324a88d212ae497c90f602452373b
5
+ SHA512:
6
+ metadata.gz: e8d303d2d70e8e2bee59e84cbe7e512494233361a347c4e938550f21ddae1e6937c4e60cba11a3a9358388d7751f315388f074d4656c2a4309e75972918db988
7
+ data.tar.gz: c5b6c4c975352d732b1f91696f4f3f65677bbc9654e3e6ce39c0aa071044e36b4d3deacb9fc28e8ed9c2ca43417fd662b15d9b25505b219a3b306f300ca1d43a
data/LICENSE.txt ADDED
@@ -0,0 +1,15 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrey Samsonov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
data/README.md ADDED
@@ -0,0 +1,370 @@
1
+ # GenerativeUI
2
+
3
+ GenerativeUI lets RubyLLM apps render model-generated UI from a declared component catalog. The wire shape is inspired by A2UI: the model emits a validated component tree, and your app renders it with Rails partials, ViewComponent, JSON, or a custom renderer.
4
+
5
+ > Disclaimer: GenerativeUI is currently experimental and under active development. Its APIs, behavior, and integration patterns may change without notice, and it is not recommended for production use yet.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "generative_ui"
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ Create `app/models/application_generative_catalog.rb`:
17
+
18
+ ```ruby
19
+ class ApplicationGenerativeCatalog < GenerativeUI::Catalog
20
+ component "Text" do
21
+ desc "Render plain text."
22
+ attributes { string :text }
23
+ end
24
+
25
+ component "Card" do
26
+ desc "Group a title with generated body content."
27
+ attributes do
28
+ one_component :title, only: "Text"
29
+ many_components :children, only: "Text"
30
+ end
31
+ end
32
+ end
33
+ ```
34
+
35
+ Component attributes use `RubyLLM::Schema`. Structural refs use `one_component` for one child id and `many_components` for ordered child ids.
36
+
37
+ Register the default catalog explicitly:
38
+
39
+ ```ruby
40
+ # config/initializers/generative_ui.rb
41
+ GenerativeUI.configure do |config|
42
+ config.catalog :default, "ApplicationGenerativeCatalog"
43
+ end
44
+ ```
45
+
46
+ Use a string in the initializer so Rails can autoload/reload the catalog class normally.
47
+
48
+ The default `:partial` renderer maps component names to partials. For this quick start, create the two partials used by the catalog above:
49
+
50
+ ```erb
51
+ <%# app/views/generative_ui/_card.html.erb %>
52
+ <%# locals: (title:, children:) %>
53
+ <section style="border:2px solid #7c3aed;border-radius:16px;padding:16px;background:#faf5ff">
54
+ <header style="font-size:22px;font-weight:700;color:#5b21b6"><%= title %></header>
55
+ <% children.each do |child| %>
56
+ <div style="margin-top:10px"><%= child %></div>
57
+ <% end %>
58
+ </section>
59
+ ```
60
+
61
+ ```erb
62
+ <%# app/views/generative_ui/_text.html.erb %>
63
+ <%# locals: (text:) %>
64
+ <p style="margin:0;color:#111827;line-height:1.45"><%= text %></p>
65
+ ```
66
+
67
+ Give a RubyLLM Rails chat a catalog-bound generate-UI tool. This assumes RubyLLM's Rails chat UI is installed:
68
+
69
+ ```bash
70
+ bin/rails generate ruby_llm:install
71
+ bin/rails db:migrate
72
+ bin/rails generate ruby_llm:chat_ui
73
+ ```
74
+
75
+ ```ruby
76
+ tool = GenerativeUI::Tool.new
77
+
78
+ chat = Chat.create!
79
+ chat.with_instructions(<<~PROMPT)
80
+ Tool guidance:
81
+ - Use generate_ui for responses that should be rendered as UI from the available components.
82
+ - IMPORTANT: after calling generate_ui, do not add a final text answer.
83
+ The tool call itself is the user-visible UI response.
84
+ PROMPT
85
+ chat.with_tool(tool)
86
+
87
+ chat.ask("What programming language was designed to make developers happy and also turned out to be especially token-efficient for LLMs? Name its iconic web framework too, and present the answer as a titled card with one short explanation.")
88
+ ```
89
+
90
+ `GenerativeUI::Tool.new` and `render_generative_ui` use the configured `:default` catalog unless you pass `catalog:`.
91
+
92
+ The `Tool guidance` section tells the model **when** to use the tool and that the tool call is the user-visible answer. The tool description itself tells the model **how** to construct valid arguments from the selected catalog.
93
+
94
+ Render the chat transcript:
95
+
96
+ ```erb
97
+ <%= render @chat.messages %>
98
+ ```
99
+
100
+ The gem ships two Rails chat partials for RubyLLM's default message views:
101
+
102
+ ```text
103
+ app/views/messages/tool_calls/_generate_ui.html.erb
104
+ app/views/messages/tool_results/_generate_ui.html.erb
105
+ ```
106
+
107
+ The shipped tool-call partial renders valid `generate_ui` calls. The tool-result partial is empty so validation status payloads stay out of the transcript.
108
+
109
+ The partial hides only `InvalidComponentTreeError`; configuration and rendering errors still raise.
110
+
111
+ **Catalog identity in Rails.** Persisted tool-call arguments do not store catalog identity. If you use named catalogs, build the tool and render with the same catalog:
112
+
113
+ ```ruby
114
+ tool = GenerativeUI::Tool.new(catalog: :support)
115
+ chat.with_tool(tool)
116
+ ```
117
+
118
+ ```erb
119
+ <%# app/views/messages/tool_calls/_generate_ui.html.erb %>
120
+ <% begin %>
121
+ <%= render_generative_ui tool_call.arguments, catalog: :support %>
122
+ <% rescue GenerativeUI::InvalidComponentTreeError %>
123
+ <% end %>
124
+ ```
125
+
126
+ If one transcript can contain UI calls from different catalogs, use a shared render catalog or route catalogs in your overridden partial.
127
+
128
+ Renderers receive materialized Ruby attributes: declared fields are `snake_case`, `one_component` refs become one rendered fragment, and `many_components` refs become arrays.
129
+
130
+ ## How it works
131
+
132
+ The gem is built around the bundled [`GenerativeUI::Tool`](lib/generative_ui/tool.rb). It uses a tool call as the transport for the generated UI tree. The call arguments contain the full payload: component names, declared attributes, and structural references between components.
133
+
134
+ ```json
135
+ {
136
+ "components": [
137
+ { "id": "root", "component": "Card", "title": "title-1", "children": ["body-1"] },
138
+ { "id": "title-1", "component": "Text", "text": "Ruby and Ruby on Rails" },
139
+ { "id": "body-1", "component": "Text", "text": "Ruby was designed to make developers happy, and Rails became its iconic web framework." }
140
+ ]
141
+ }
142
+ ```
143
+
144
+ JSON Schema constrains each component's attributes. Runtime validation handles tree rules that schema cannot express: root, refs, cycles, reachability, and `only:` targets.
145
+
146
+ The tool returns only validation status, not rendered UI:
147
+
148
+ ```json
149
+ { "status": "ok" }
150
+ ```
151
+
152
+ or:
153
+
154
+ ```json
155
+ { "status": "invalid", "errors": { "...": ["..."] } }
156
+ ```
157
+
158
+ Each component declaration contributes one component to the catalog. It declares the component name, its model-facing description, its attribute schema, structural references to other components, and optional render-target metadata. The selected catalog is then compiled into both tool guidance and the provider-facing schema for `GenerativeUI::Tool`.
159
+
160
+ ## Plain RubyLLM
161
+
162
+ Rails chat views are just one integration path. Plain RubyLLM uses the same catalog and tool; capture the `generate_ui` call and render its arguments yourself:
163
+
164
+ ```ruby
165
+ tool = GenerativeUI::Tool.new(catalog: MyCatalog)
166
+ ui_call = nil
167
+
168
+ chat = RubyLLM.chat
169
+ .with_instructions(<<~PROMPT)
170
+ Tool guidance:
171
+ - Use generate_ui for responses that should be rendered as UI from the available components.
172
+ - IMPORTANT: after calling generate_ui, do not add a final text answer.
173
+ The tool call itself is the user-visible UI response.
174
+ PROMPT
175
+ .with_tools(tool)
176
+ .before_tool_call do |call|
177
+ ui_call = call if call.name == "generate_ui"
178
+ end
179
+
180
+ chat.ask("What programming language was designed to make developers happy and also turned out to be especially token-efficient for LLMs? Name its iconic web framework too, and present the answer as a titled card with one short explanation.")
181
+
182
+ GenerativeUI.render(ui_call.arguments, catalog: MyCatalog, renderer: :json)
183
+ # => {
184
+ # "component" => "Card",
185
+ # "props" => {
186
+ # "title" => { "component" => "Text", "props" => { "text" => "Ruby and Ruby on Rails" } },
187
+ # "children" => [
188
+ # {
189
+ # "component" => "Text",
190
+ # "props" => {
191
+ # "text" => "Ruby was created to make developers happy, and its concise style is often very token-efficient; its iconic framework is Ruby on Rails."
192
+ # }
193
+ # }
194
+ # ]
195
+ # }
196
+ # }
197
+ ```
198
+
199
+ `Renderers::Json` returns nested JSON by default; pass a renderer instance for options such as `mode: :flat`:
200
+
201
+ ```ruby
202
+ renderer = GenerativeUI::Renderers::Json.new(mode: :flat)
203
+ GenerativeUI.render(ui_call.arguments, catalog: MyCatalog, renderer:)
204
+ ```
205
+
206
+ ## Named catalogs
207
+
208
+ ```ruby
209
+ class SupportCatalog < GenerativeUI::Catalog
210
+ component "TicketSummary" do
211
+ attributes { string :summary }
212
+ end
213
+ end
214
+
215
+ GenerativeUI.configure do |config|
216
+ config.catalog :support, SupportCatalog
217
+ end
218
+ ```
219
+
220
+ Pass the registered name where the default would go:
221
+
222
+ ```ruby
223
+ GenerativeUI::Tool.new(catalog: :support)
224
+ render_generative_ui(args, catalog: :support)
225
+ ```
226
+
227
+ Prefer `snake_case` in the Ruby DSL. Tool schemas and payloads use `camelCase`; unsafe acronym forms such as `imageURL` are rejected.
228
+
229
+ ```ruby
230
+ attributes do
231
+ array :tab_items do
232
+ object do
233
+ string :display_name
234
+ end
235
+ end
236
+ end
237
+ ```
238
+
239
+ ```json
240
+ {
241
+ "tabItems": [
242
+ { "displayName": "..." }
243
+ ]
244
+ }
245
+ ```
246
+
247
+ Structural references can also appear inside nested value schemas:
248
+
249
+ ```ruby
250
+ attributes do
251
+ array :tab_items do
252
+ object do
253
+ string :title
254
+ one_component :content
255
+ end
256
+ end
257
+ end
258
+ ```
259
+
260
+ **Subclassing.** Catalog declarations are per class; subclasses do not inherit components. Share declarations with a module if needed:
261
+
262
+ ```ruby
263
+ module SharedComponents
264
+ def self.included(base)
265
+ base.component("Text") { attributes { string :text } }
266
+ end
267
+ end
268
+
269
+ class ChatCatalog < GenerativeUI::Catalog
270
+ include SharedComponents
271
+ component("ChatBubble") { attributes { string :text } }
272
+ end
273
+ ```
274
+
275
+ ## `present_with` and the resolution chain
276
+
277
+ `present_with` binds a component to a render target at two scopes:
278
+
279
+ 1. **Per-component override** — `present_with :adapter, target` inside a `component` block.
280
+ 2. **Catalog-wide default** — `present_with :adapter do |name| … end` at the catalog class level. The block receives the component name and returns the target.
281
+ 3. **Built-in `Conventions`** — gem fallback, used when neither scope above provides a target.
282
+
283
+ Built-in fallbacks:
284
+
285
+ | Adapter | Fallback |
286
+ |-------------------|--------------------------------------------------------------------------|
287
+ | `:partial` | `"generative_ui/#{name.underscore}"` (e.g. `"generative_ui/card"`) |
288
+ | `:view_component` | `"GenerativeUI::#{name.camelize}Component"` → `constantize` to the class |
289
+ | `:json` | No target — JSON renderer serializes the component tree directly |
290
+
291
+ Use `present_with` to redirect individual components or to set a catalog-wide convention that differs from the gem default:
292
+
293
+ ```ruby
294
+ class ApplicationGenerativeCatalog < GenerativeUI::Catalog
295
+ present_with :partial do |name|
296
+ "components/#{name.underscore}"
297
+ end
298
+
299
+ component "Text" do
300
+ desc "Render plain text."
301
+ attributes { string :text }
302
+ present_with :partial, "shared/widgets/text"
303
+ end
304
+ end
305
+ ```
306
+
307
+ Apps using ViewComponent declare bindings for the `:view_component` adapter instead — same DSL, different target type:
308
+
309
+ ```ruby
310
+ component "Card" do
311
+ attributes { ... }
312
+ present_with :view_component, CardComponent
313
+ end
314
+ ```
315
+
316
+ With the ViewComponent renderer, declared attributes and materialized refs arrive as keyword arguments:
317
+
318
+ ```ruby
319
+ class GenerativeUI::CardComponent < ViewComponent::Base
320
+ def initialize(title:, children:)
321
+ @title = title
322
+ @children = children
323
+ end
324
+ end
325
+ ```
326
+
327
+ ## Validation model
328
+
329
+ Provider-facing schemas guide generation; runtime validation decides what the application accepts.
330
+
331
+ For a complete tool call, accepted components must form exactly one rooted tree:
332
+
333
+ - one component has `id: "root"`;
334
+ - ids are unique;
335
+ - every structural reference resolves;
336
+ - every component is reachable from `root`;
337
+ - cycles and shared children are rejected;
338
+ - `only:` constraints match the referenced component types.
339
+
340
+ `id` syntax is otherwise unconstrained in v1.
341
+
342
+ ## Renderers
343
+
344
+ The gem ships with:
345
+
346
+ - `GenerativeUI::Renderers::Partial`
347
+ - `GenerativeUI::Renderers::ViewComponent`
348
+ - `GenerativeUI::Renderers::Json`
349
+
350
+ Register a custom renderer when your app uses another rendering system. The factory receives `view_context` and returns an object responding to `call(component_set)`:
351
+
352
+ ```ruby
353
+ GenerativeUI.configure do |config|
354
+ config.register_renderer(:phlex) do |view_context|
355
+ PhlexRenderer.new(view_context:)
356
+ end
357
+
358
+ config.default_renderer = :phlex
359
+ end
360
+ ```
361
+
362
+ Then choose it per call if needed:
363
+
364
+ ```erb
365
+ <%= render_generative_ui tool_call.arguments, catalog: :support, renderer: :phlex %>
366
+ ```
367
+
368
+ ## License
369
+
370
+ MIT.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ module ViewHelper
5
+ def render_generative_ui(arguments, catalog: :default, renderer: nil)
6
+ renderer ||= GenerativeUI.configuration.default_renderer
7
+ GenerativeUI.render(arguments, catalog:, renderer:, view_context: self)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ <% begin %>
2
+ <%= render_generative_ui tool_call.arguments, catalog: :default %>
3
+ <% rescue GenerativeUI::InvalidComponentTreeError => e %>
4
+ <%# Invalid attempts stay hidden; subscribe to the notification below to log/report them. %>
5
+ <% ActiveSupport::Notifications.instrument('invalid_tree.generative_ui', error: e, tool_call: tool_call) %>
6
+ <% end %>
@@ -0,0 +1 @@
1
+ <%# GenerativeUI::Tool results are control messages for the model, not user-visible transcript content. %>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/generative_ui/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "generative_ui"
7
+ spec.version = GenerativeUI::VERSION
8
+ spec.authors = [ "Andrey Samsonov" ]
9
+ spec.email = [ "me@samsonov.io" ]
10
+
11
+ spec.summary = "Catalog-driven generative UI for RubyLLM and Rails"
12
+ spec.description = "Define a safe component catalog, expose it as a RubyLLM tool schema, validate model-generated component trees, and render them with Rails partials, ViewComponent, or JSON."
13
+ spec.homepage = "https://github.com/kryzhovnik/generative_ui"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
+
17
+ spec.metadata["source_code_uri"] = spec.homepage
18
+
19
+ spec.files = Dir[
20
+ "lib/**/*",
21
+ "app/**/*",
22
+ "README.md",
23
+ "LICENSE*",
24
+ "generative_ui.gemspec"
25
+ ]
26
+ spec.require_paths = [ "lib" ]
27
+
28
+ spec.add_dependency "activesupport", ">= 7.0"
29
+ spec.add_dependency "ruby_llm", "~> 1.15"
30
+ spec.add_dependency "json_schemer", "~> 2.5"
31
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerativeUI
4
+ class Attributes
5
+ METADATA_KEY = :_generative_ui_structural_ref
6
+
7
+ module StructuralDsl
8
+ def one_component(name, description: nil, required: true, only: nil)
9
+ add_property(
10
+ name,
11
+ {
12
+ type: 'string',
13
+ description: description,
14
+ Attributes::METADATA_KEY => {
15
+ cardinality: :one,
16
+ only: Attributes.normalize_only(only)
17
+ }
18
+ }.compact,
19
+ required: required
20
+ )
21
+ end
22
+
23
+ def many_components(name, description: nil, required: true, only: nil, min_items: nil, max_items: nil)
24
+ add_property(
25
+ name,
26
+ {
27
+ type: 'array',
28
+ items: { type: 'string' },
29
+ description: description,
30
+ minItems: min_items,
31
+ maxItems: max_items,
32
+ Attributes::METADATA_KEY => {
33
+ cardinality: :many,
34
+ only: Attributes.normalize_only(only)
35
+ }
36
+ }.compact,
37
+ required: required
38
+ )
39
+ end
40
+ end
41
+
42
+ module LocalSchemaBuilders
43
+ def object_schema(description: nil, of: nil, &block)
44
+ return determine_object_reference(of, description) if of
45
+
46
+ sub_schema = Class.new(self)
47
+ result = sub_schema.class_eval(&block)
48
+
49
+ if result.is_a?(Hash) && result['$ref'] && sub_schema.properties.empty?
50
+ result.merge(description ? { description: description } : {})
51
+ elsif schema_class?(result) && sub_schema.properties.empty?
52
+ schema_class_to_inline_schema(result).merge(description ? { description: description } : {})
53
+ else
54
+ {
55
+ type: 'object',
56
+ properties: sub_schema.properties,
57
+ required: sub_schema.required_properties,
58
+ additionalProperties: sub_schema.additional_properties,
59
+ description: description
60
+ }.compact
61
+ end
62
+ end
63
+ end
64
+
65
+ class Schema < RubyLLM::Schema
66
+ extend StructuralDsl
67
+ extend LocalSchemaBuilders
68
+
69
+ class << self
70
+ def create(&block)
71
+ Class.new(self).tap { |schema_class| schema_class.class_eval(&block) }
72
+ end
73
+ end
74
+ end
75
+
76
+ class << self
77
+ def build(&block)
78
+ schema_class = Schema.create(&block)
79
+ new(schema_class)
80
+ end
81
+
82
+ def normalize_only(value)
83
+ return nil if value.nil?
84
+
85
+ Array(value).map(&:to_s)
86
+ end
87
+ end
88
+
89
+ attr_reader :schema_class
90
+
91
+ def initialize(schema_class)
92
+ @schema_class = schema_class
93
+ end
94
+
95
+ def json_schema
96
+ @json_schema ||= camelize_schema(schema_class.new.to_json_schema.fetch(:schema))
97
+ end
98
+
99
+ def structural_refs
100
+ @structural_refs ||= extract_structural_refs(json_schema)
101
+ end
102
+
103
+ private
104
+
105
+ def camelize_schema(value)
106
+ case value
107
+ when Hash
108
+ value.each_with_object({}) do |(key, child), transformed|
109
+ transformed[key] = case key.to_sym
110
+ when :properties
111
+ child.each_with_object({}) do |(property, schema), properties|
112
+ properties[camelize_name(property)] = camelize_schema(schema)
113
+ end
114
+ when :required
115
+ child.map { |name| camelize_name(name) }
116
+ else
117
+ camelize_schema(child)
118
+ end
119
+ end
120
+ when Array
121
+ value.map { |child| camelize_schema(child) }
122
+ else
123
+ value
124
+ end
125
+ end
126
+
127
+ def camelize_name(name)
128
+ name.to_s.camelize(:lower).to_sym
129
+ end
130
+
131
+ def extract_structural_refs(schema)
132
+ refs = []
133
+ walk_schema(schema, [], refs)
134
+ refs
135
+ end
136
+
137
+ def walk_schema(schema, path, refs)
138
+ return unless schema.is_a?(Hash)
139
+
140
+ if (metadata = schema[METADATA_KEY])
141
+ refs << StructuralRef.new(
142
+ path: path,
143
+ cardinality: metadata.fetch(:cardinality),
144
+ required: required_path?(path),
145
+ only: metadata[:only],
146
+ description: schema[:description] || schema['description'],
147
+ min_items: schema[:minItems] || schema['minItems'],
148
+ max_items: schema[:maxItems] || schema['maxItems']
149
+ )
150
+ end
151
+
152
+ properties = schema[:properties] || schema['properties'] || {}
153
+ properties.each do |name, child|
154
+ walk_schema(child, path + [name.to_sym], refs)
155
+ end
156
+
157
+ items = schema[:items] || schema['items']
158
+ walk_schema(items, path + [:*], refs) if items
159
+
160
+ Array(schema[:oneOf] || schema['oneOf']).each { |child| walk_schema(child, path, refs) }
161
+ Array(schema[:anyOf] || schema['anyOf']).each { |child| walk_schema(child, path, refs) }
162
+ end
163
+
164
+ def required_path?(path)
165
+ current = json_schema
166
+
167
+ path.each do |segment|
168
+ if segment == :*
169
+ current = current[:items] || current['items']
170
+ next
171
+ end
172
+
173
+ required = current[:required] || current['required'] || []
174
+ return false unless required.map(&:to_sym).include?(segment)
175
+
176
+ current = (current[:properties] || current['properties']).fetch(segment)
177
+ end
178
+
179
+ true
180
+ end
181
+ end
182
+ end