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