a2ui-rails 0.1.0
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/README.md +373 -0
- data/lib/a2ui/modules/action_handler.rb +25 -0
- data/lib/a2ui/modules/data_validator.rb +22 -0
- data/lib/a2ui/modules/input_parser.rb +25 -0
- data/lib/a2ui/modules/layout_adapter.rb +25 -0
- data/lib/a2ui/modules/surface.rb +66 -0
- data/lib/a2ui/modules/surface_manager.rb +64 -0
- data/lib/a2ui/modules/ui_generator.rb +25 -0
- data/lib/a2ui/modules/ui_updater.rb +26 -0
- data/lib/a2ui/modules.rb +17 -0
- data/lib/a2ui/signatures/adapt_layout.rb +19 -0
- data/lib/a2ui/signatures/generate_ui.rb +20 -0
- data/lib/a2ui/signatures/handle_action.rb +23 -0
- data/lib/a2ui/signatures/parse_input.rb +21 -0
- data/lib/a2ui/signatures/update_ui.rb +21 -0
- data/lib/a2ui/signatures/validate_data.rb +18 -0
- data/lib/a2ui/signatures.rb +12 -0
- data/lib/a2ui/types/action.rb +9 -0
- data/lib/a2ui/types/action_response_type.rb +13 -0
- data/lib/a2ui/types/alignment.rb +13 -0
- data/lib/a2ui/types/boolean_value.rb +9 -0
- data/lib/a2ui/types/button_component.rb +12 -0
- data/lib/a2ui/types/card_component.rb +11 -0
- data/lib/a2ui/types/check_box_component.rb +10 -0
- data/lib/a2ui/types/column_component.rb +12 -0
- data/lib/a2ui/types/context_binding.rb +9 -0
- data/lib/a2ui/types/data_driven_children.rb +9 -0
- data/lib/a2ui/types/data_update.rb +9 -0
- data/lib/a2ui/types/distribution.rb +15 -0
- data/lib/a2ui/types/divider_component.rb +9 -0
- data/lib/a2ui/types/explicit_children.rb +8 -0
- data/lib/a2ui/types/icon_component.rb +10 -0
- data/lib/a2ui/types/image_component.rb +11 -0
- data/lib/a2ui/types/image_fit.rb +14 -0
- data/lib/a2ui/types/input_type.rb +16 -0
- data/lib/a2ui/types/list_component.rb +10 -0
- data/lib/a2ui/types/literal_value.rb +8 -0
- data/lib/a2ui/types/number_value.rb +9 -0
- data/lib/a2ui/types/object_value.rb +9 -0
- data/lib/a2ui/types/orientation.rb +11 -0
- data/lib/a2ui/types/path_reference.rb +8 -0
- data/lib/a2ui/types/row_component.rb +12 -0
- data/lib/a2ui/types/screen_size.rb +12 -0
- data/lib/a2ui/types/select_component.rb +12 -0
- data/lib/a2ui/types/slider_component.rb +13 -0
- data/lib/a2ui/types/stream_action.rb +16 -0
- data/lib/a2ui/types/stream_op.rb +10 -0
- data/lib/a2ui/types/string_value.rb +9 -0
- data/lib/a2ui/types/text_component.rb +10 -0
- data/lib/a2ui/types/text_field_component.rb +13 -0
- data/lib/a2ui/types/text_usage_hint.rb +16 -0
- data/lib/a2ui/types/user_action.rb +11 -0
- data/lib/a2ui/types/validation_issue.rb +10 -0
- data/lib/a2ui/types.rb +91 -0
- data/lib/a2ui/version.rb +6 -0
- data/lib/a2ui.rb +7 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 35be118652b4117d296da399cf1c3cd845d479b98353dd3ce2b449ef9d7d9886
|
|
4
|
+
data.tar.gz: 32d8a2385bf14245720a82ffaa6cfedc8e63249d0b79ab5891c9f530891be772
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9e53301459ba5aa690c88057806b667facd5ef09d6a702cbdfbd31071f725970e3a31c0a7c9edbe0847f6464e0904eaaf8a06fa4b0356a0e6d859054bc9f81f6
|
|
7
|
+
data.tar.gz: 3195cebf0c869b6fdf36639cb91ab19819d37ef85623bae1086f4dc95752e8e574033373a80a27910f6b297e3bf6b0cf4d81310e2ca6e944ce854e1bdf46934f
|
data/README.md
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# A2UI Rails
|
|
2
|
+
|
|
3
|
+
A Ruby port of [Google's A2UI](https://github.com/google/A2UI) (Agent-to-User Interface) for Rails, using Turbo Streams and DSPy.rb for LLM-driven UI generation.
|
|
4
|
+
|
|
5
|
+
> **Status**: Early development. APIs will change.
|
|
6
|
+
|
|
7
|
+
## What is A2UI?
|
|
8
|
+
|
|
9
|
+
A2UI lets AI agents generate rich, interactive UIs by shipping **data and UI descriptions together** as structured output, rather than executable code. The client maintains a catalog of trusted components that the agent references by type.
|
|
10
|
+
|
|
11
|
+
This port maps A2UI concepts to Rails + Turbo:
|
|
12
|
+
|
|
13
|
+
| A2UI | Rails + Turbo |
|
|
14
|
+
|------|---------------|
|
|
15
|
+
| Surface | `<turbo-frame>` |
|
|
16
|
+
| `surfaceUpdate` | `<turbo-stream>` |
|
|
17
|
+
| `dataModelUpdate` | Stimulus controller values |
|
|
18
|
+
| Component catalog | ViewComponent library |
|
|
19
|
+
| JSON adjacency list | Rendered HTML fragments |
|
|
20
|
+
|
|
21
|
+
## DSPy Pipeline
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
25
|
+
│ A2UI DSPy Pipelines │
|
|
26
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
27
|
+
│ │
|
|
28
|
+
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
29
|
+
│ │ CREATE SURFACE │ │
|
|
30
|
+
│ │ │ │
|
|
31
|
+
│ │ "Create a booking form" ───▶ GenerateUI (ChainOfThought) │ │
|
|
32
|
+
│ │ + surface_id │ │ │
|
|
33
|
+
│ │ + available_data ┌──────┴──────┐ │ │
|
|
34
|
+
│ │ ▼ ▼ │ │
|
|
35
|
+
│ │ root_id components[] │ │
|
|
36
|
+
│ │ "form-1" [Column, TextField, │ │
|
|
37
|
+
│ │ TextField, Button] │ │
|
|
38
|
+
│ └─────────────────────────────────────────────────────────────────────┘ │
|
|
39
|
+
│ │
|
|
40
|
+
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
41
|
+
│ │ UPDATE SURFACE │ │
|
|
42
|
+
│ │ │ │
|
|
43
|
+
│ │ "Add phone field" ───▶ UpdateUI (ChainOfThought) │ │
|
|
44
|
+
│ │ + current_components │ │ │
|
|
45
|
+
│ │ + current_data ┌──────┴──────┐ │ │
|
|
46
|
+
│ │ ▼ ▼ │ │
|
|
47
|
+
│ │ streams[] new_components[] │ │
|
|
48
|
+
│ │ [{action: [TextFieldComponent] │ │
|
|
49
|
+
│ │ "after", │ │
|
|
50
|
+
│ │ target: │ │
|
|
51
|
+
│ │ "email"}] │ │
|
|
52
|
+
│ └─────────────────────────────────────────────────────────────────────┘ │
|
|
53
|
+
│ │
|
|
54
|
+
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
55
|
+
│ │ HANDLE ACTION │ │
|
|
56
|
+
│ │ │ │
|
|
57
|
+
│ │ UserAction{name,context} ───▶ HandleAction (ChainOfThought) │ │
|
|
58
|
+
│ │ + business_rules │ │ │
|
|
59
|
+
│ │ + current_data ┌───────┼───────┐ │ │
|
|
60
|
+
│ │ ▼ ▼ ▼ │ │
|
|
61
|
+
│ │ response streams data_updates │ │
|
|
62
|
+
│ │ _type [] [{path: "/booking", │ │
|
|
63
|
+
│ │ :update_ui entries: [...]}] │ │
|
|
64
|
+
│ └─────────────────────────────────────────────────────────────────────┘ │
|
|
65
|
+
│ │
|
|
66
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### How It Works
|
|
70
|
+
|
|
71
|
+
**Signals (Inputs):**
|
|
72
|
+
- `request` — Natural language describing what to build/change
|
|
73
|
+
- `available_data` / `current_data` — JSON data model the UI binds to
|
|
74
|
+
- `current_components` — Existing component tree for incremental updates
|
|
75
|
+
- `business_rules` — Domain constraints for action handling
|
|
76
|
+
|
|
77
|
+
**Decisions (LLM Reasoning via ChainOfThought):**
|
|
78
|
+
1. **Component Selection** — Which component types fit the request?
|
|
79
|
+
2. **Layout Structure** — How to arrange components (Row vs Column, nesting)?
|
|
80
|
+
3. **Data Binding** — Which JSON Pointer paths connect to which fields?
|
|
81
|
+
4. **Action Mapping** — What context to capture when buttons are clicked?
|
|
82
|
+
5. **Stream Operations** — For updates: append, replace, or remove?
|
|
83
|
+
|
|
84
|
+
**Outputs (Structured):**
|
|
85
|
+
- `components[]` — Flat adjacency list of typed component structs
|
|
86
|
+
- `root_id` — Entry point for rendering the tree
|
|
87
|
+
- `streams[]` — Turbo Stream operations (action + target + content)
|
|
88
|
+
- `data_updates[]` — Mutations to apply to the data model
|
|
89
|
+
|
|
90
|
+
The LLM never generates code—only typed data structures that map to trusted ViewComponents.
|
|
91
|
+
|
|
92
|
+
## Installation
|
|
93
|
+
|
|
94
|
+
Add to your Gemfile:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
gem 'dspy', '~> 0.34'
|
|
98
|
+
gem 'sorbet-runtime'
|
|
99
|
+
|
|
100
|
+
# Choose your LLM provider:
|
|
101
|
+
gem 'dspy-openai' # OpenAI, OpenRouter, Ollama
|
|
102
|
+
# gem 'dspy-anthropic' # Claude
|
|
103
|
+
# gem 'dspy-gemini' # Gemini
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Copy the `lib/a2ui/` and `app/` directories to your Rails app.
|
|
107
|
+
|
|
108
|
+
Configure DSPy in an initializer:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# config/initializers/dspy.rb
|
|
112
|
+
DSPy.configure do |c|
|
|
113
|
+
c.lm = DSPy::LM.new('openai/gpt-4o-mini', api_key: ENV['OPENAI_API_KEY'])
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Add the inflection for proper constant loading:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# config/initializers/inflections.rb
|
|
121
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
122
|
+
inflect.acronym 'A2UI'
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Quick Start
|
|
127
|
+
|
|
128
|
+
### Generate UI from Natural Language
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
manager = A2UI::SurfaceManager.new
|
|
132
|
+
|
|
133
|
+
# Create a surface from a natural language request
|
|
134
|
+
surface = manager.create(
|
|
135
|
+
surface_id: 'booking-form',
|
|
136
|
+
request: 'Create a booking form with guest count, date picker, and submit button',
|
|
137
|
+
data: '{"booking": {"guests": 2}}'
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Render in your view
|
|
141
|
+
render partial: 'a2ui/surface', locals: { surface: surface }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Handle User Actions
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
action = A2UI::UserAction.new(
|
|
148
|
+
name: 'submit_booking',
|
|
149
|
+
surface_id: 'booking-form',
|
|
150
|
+
source_id: 'submit-btn',
|
|
151
|
+
context: { 'guests' => '3', 'date' => '2025-01-15' }
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
result = manager.handle_action(
|
|
155
|
+
action: action,
|
|
156
|
+
business_rules: 'Maximum 10 guests per booking'
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# result.response_type => A2UI::ActionResponseType::UpdateUI
|
|
160
|
+
# result.streams => [A2UI::StreamOp, ...]
|
|
161
|
+
# result.components => [A2UI::Component, ...]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Update Existing UI
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
result = manager.update(
|
|
168
|
+
surface_id: 'booking-form',
|
|
169
|
+
request: 'Add a phone number field after the email'
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Returns Turbo Stream operations to apply
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Architecture
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
179
|
+
│ Your Rails App │
|
|
180
|
+
├─────────────────────────────────────────────────────────────┤
|
|
181
|
+
│ │
|
|
182
|
+
│ DSPy Signatures DSPy Modules Controllers │
|
|
183
|
+
│ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
|
|
184
|
+
│ │ GenerateUI │────────▶│ UIGenerator │─────▶│ Surfaces │ │
|
|
185
|
+
│ │ UpdateUI │ │ UIUpdater │ │ Actions │ │
|
|
186
|
+
│ │ HandleAction│ │ ActionHndlr │ └───────────┘ │
|
|
187
|
+
│ └─────────────┘ └─────────────┘ │ │
|
|
188
|
+
│ │ │ │ │
|
|
189
|
+
│ ▼ ▼ ▼ │
|
|
190
|
+
│ ┌─────────────────────────────────────────────────────────┐│
|
|
191
|
+
│ │ A2UI::Components::Renderer ││
|
|
192
|
+
│ │ Maps Component structs → ViewComponents ││
|
|
193
|
+
│ └─────────────────────────────────────────────────────────┘│
|
|
194
|
+
│ │ │
|
|
195
|
+
│ ▼ │
|
|
196
|
+
│ ┌─────────────────────────────────────────────────────────┐│
|
|
197
|
+
│ │ Turbo Streams / Frames ││
|
|
198
|
+
│ └─────────────────────────────────────────────────────────┘│
|
|
199
|
+
└─────────────────────────────────────────────────────────────┘
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Core Concepts
|
|
203
|
+
|
|
204
|
+
### Signatures (DSPy)
|
|
205
|
+
|
|
206
|
+
Type-safe interfaces for LLM calls using Sorbet types:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
class A2UI::GenerateUI < DSPy::Signature
|
|
210
|
+
description 'Generate UI components from natural language.'
|
|
211
|
+
|
|
212
|
+
input do
|
|
213
|
+
const :request, String
|
|
214
|
+
const :surface_id, String
|
|
215
|
+
const :available_data, String, default: '{}'
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
output do
|
|
219
|
+
const :root_id, String
|
|
220
|
+
const :components, T::Array[Component] # Union type
|
|
221
|
+
const :initial_data, T::Array[DataUpdate], default: []
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Union Types
|
|
227
|
+
|
|
228
|
+
Components and values use discriminated unions for type safety:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
# Value is either literal or a path reference
|
|
232
|
+
A2UI::Value = T.any(A2UI::LiteralValue, A2UI::PathReference)
|
|
233
|
+
|
|
234
|
+
# Children are either explicit IDs or data-driven
|
|
235
|
+
A2UI::Children = T.any(A2UI::ExplicitChildren, A2UI::DataDrivenChildren)
|
|
236
|
+
|
|
237
|
+
# Component is a union of all component types
|
|
238
|
+
A2UI::Component = T.any(
|
|
239
|
+
A2UI::TextComponent,
|
|
240
|
+
A2UI::ButtonComponent,
|
|
241
|
+
A2UI::TextFieldComponent,
|
|
242
|
+
A2UI::RowComponent,
|
|
243
|
+
A2UI::ColumnComponent,
|
|
244
|
+
# ... 13 total
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
DSPy automatically handles `_type` discrimination in LLM responses.
|
|
249
|
+
|
|
250
|
+
### Components
|
|
251
|
+
|
|
252
|
+
Each component type maps to a ViewComponent:
|
|
253
|
+
|
|
254
|
+
| Struct | ViewComponent | Purpose |
|
|
255
|
+
|--------|---------------|---------|
|
|
256
|
+
| `TextComponent` | `A2UI::Components::Text` | Display text with semantic hints |
|
|
257
|
+
| `ButtonComponent` | `A2UI::Components::Button` | Trigger actions |
|
|
258
|
+
| `TextFieldComponent` | `A2UI::Components::TextField` | Text input with data binding |
|
|
259
|
+
| `RowComponent` | `A2UI::Components::Row` | Horizontal flex layout |
|
|
260
|
+
| `ColumnComponent` | `A2UI::Components::Column` | Vertical flex layout |
|
|
261
|
+
| `CardComponent` | `A2UI::Components::Card` | Container with elevation |
|
|
262
|
+
| `CheckBoxComponent` | `A2UI::Components::CheckBox` | Boolean input |
|
|
263
|
+
| `DividerComponent` | `A2UI::Components::Divider` | Visual separator |
|
|
264
|
+
|
|
265
|
+
### Data Binding
|
|
266
|
+
|
|
267
|
+
Form inputs bind to the data model via JSON Pointer paths:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
# Component definition
|
|
271
|
+
A2UI::TextFieldComponent.new(
|
|
272
|
+
id: 'guest-count',
|
|
273
|
+
value: A2UI::PathReference.new(path: '/booking/guests'),
|
|
274
|
+
input_type: A2UI::InputType::Number
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Renders with Stimulus binding
|
|
278
|
+
# <input data-controller="a2ui-binding"
|
|
279
|
+
# data-a2ui-binding-path-value="/booking/guests" ...>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Actions
|
|
283
|
+
|
|
284
|
+
Buttons dispatch actions with context from the data model:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
A2UI::ButtonComponent.new(
|
|
288
|
+
id: 'submit',
|
|
289
|
+
label: A2UI::LiteralValue.new(value: 'Book Now'),
|
|
290
|
+
action: A2UI::Action.new(
|
|
291
|
+
name: 'submit_booking',
|
|
292
|
+
context: [
|
|
293
|
+
A2UI::ContextBinding.new(key: 'booking', path: '/booking')
|
|
294
|
+
]
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Stimulus Controllers
|
|
300
|
+
|
|
301
|
+
Three controllers handle client-side behavior:
|
|
302
|
+
|
|
303
|
+
- **`a2ui-data`** - Manages surface data model (JSON Pointer get/set)
|
|
304
|
+
- **`a2ui-binding`** - Two-way binding between inputs and data model
|
|
305
|
+
- **`a2ui-action`** - Dispatches user actions to server via fetch
|
|
306
|
+
|
|
307
|
+
## Routes
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
namespace :a2ui do
|
|
311
|
+
resources :surfaces, only: [:create, :show, :update, :destroy]
|
|
312
|
+
resources :actions, only: [:create]
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Testing
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
bundle exec rspec spec/a2ui/types_spec.rb # Unit tests (no API)
|
|
320
|
+
bundle exec rspec spec/a2ui/ # All tests (needs API key + VCR)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Integration tests use VCR to record LLM responses:
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
RSpec.describe A2UI::GenerateUI, :vcr do
|
|
327
|
+
it 'generates a booking form' do
|
|
328
|
+
generator = A2UI::UIGenerator.new
|
|
329
|
+
result = generator.call(
|
|
330
|
+
request: 'Create a booking form',
|
|
331
|
+
surface_id: 'booking'
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
expect(result.components).not_to be_empty
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## Development
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
# Install dependencies
|
|
343
|
+
bundle install
|
|
344
|
+
|
|
345
|
+
# Run tests
|
|
346
|
+
bundle exec rspec
|
|
347
|
+
|
|
348
|
+
# Type check (optional)
|
|
349
|
+
bundle exec srb tc
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Next Steps
|
|
353
|
+
|
|
354
|
+
- [ ] **Add LayoutEvidenceSteps** — Track layout decisions (why Column vs Row? why this nesting?) as structured reasoning evidence for debugging and optimization
|
|
355
|
+
- [ ] **Build a demo app** — Interactive playground showing surface creation, updates, and action handling in real-time
|
|
356
|
+
|
|
357
|
+
## Roadmap
|
|
358
|
+
|
|
359
|
+
- [ ] More components (Select, Slider, Tabs, Modal)
|
|
360
|
+
- [ ] Data-driven children (repeat templates from array)
|
|
361
|
+
- [ ] Optimizers for prompt tuning (MIPROv2 for signature optimization)
|
|
362
|
+
- [ ] Rails generator for scaffolding
|
|
363
|
+
- [ ] JavaScript package for standalone use
|
|
364
|
+
|
|
365
|
+
## License
|
|
366
|
+
|
|
367
|
+
MIT
|
|
368
|
+
|
|
369
|
+
## See Also
|
|
370
|
+
|
|
371
|
+
- [Google A2UI](https://github.com/google/A2UI) - Original specification
|
|
372
|
+
- [DSPy.rb](https://github.com/vicentereig/dspy.rb) - Ruby DSPy framework
|
|
373
|
+
- [Hotwired Turbo](https://github.com/hotwired/turbo) - Turbo Streams/Frames
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module A2UI
|
|
5
|
+
class ActionHandler < DSPy::Module
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { void }
|
|
9
|
+
def initialize
|
|
10
|
+
super
|
|
11
|
+
@predictor = DSPy::ChainOfThought.new(HandleAction)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sig do
|
|
15
|
+
params(input_values: T.untyped).returns(T.untyped)
|
|
16
|
+
end
|
|
17
|
+
def forward(**input_values)
|
|
18
|
+
@predictor.call(
|
|
19
|
+
action: input_values.fetch(:action),
|
|
20
|
+
current_data: input_values.fetch(:current_data, '{}'),
|
|
21
|
+
business_rules: input_values.fetch(:business_rules, '')
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module A2UI
|
|
5
|
+
class DataValidator < DSPy::Module
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { void }
|
|
9
|
+
def initialize
|
|
10
|
+
super
|
|
11
|
+
@predictor = DSPy::Predict.new(ValidateData)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sig { params(input_values: T.untyped).returns(T.untyped) }
|
|
15
|
+
def forward(**input_values)
|
|
16
|
+
@predictor.call(
|
|
17
|
+
data: input_values.fetch(:data),
|
|
18
|
+
rules: input_values.fetch(:rules)
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module A2UI
|
|
5
|
+
class InputParser < DSPy::Module
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { void }
|
|
9
|
+
def initialize
|
|
10
|
+
super
|
|
11
|
+
@predictor = DSPy::Predict.new(ParseInput)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sig do
|
|
15
|
+
params(input_values: T.untyped).returns(T.untyped)
|
|
16
|
+
end
|
|
17
|
+
def forward(**input_values)
|
|
18
|
+
@predictor.call(
|
|
19
|
+
text: input_values.fetch(:text),
|
|
20
|
+
target_path: input_values.fetch(:target_path),
|
|
21
|
+
expected_schema: input_values.fetch(:expected_schema, '')
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module A2UI
|
|
5
|
+
class LayoutAdapter < DSPy::Module
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { void }
|
|
9
|
+
def initialize
|
|
10
|
+
super
|
|
11
|
+
@predictor = DSPy::Predict.new(AdaptLayout)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sig do
|
|
15
|
+
params(input_values: T.untyped).returns(T.untyped)
|
|
16
|
+
end
|
|
17
|
+
def forward(**input_values)
|
|
18
|
+
@predictor.call(
|
|
19
|
+
components: input_values.fetch(:components),
|
|
20
|
+
root_id: input_values.fetch(:root_id),
|
|
21
|
+
screen: input_values.fetch(:screen)
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module A2UI
|
|
5
|
+
class Surface
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { returns(String) }
|
|
9
|
+
attr_reader :id
|
|
10
|
+
|
|
11
|
+
sig { returns(T.nilable(String)) }
|
|
12
|
+
attr_reader :root_id
|
|
13
|
+
|
|
14
|
+
sig { returns(T::Hash[String, Component]) }
|
|
15
|
+
attr_reader :components
|
|
16
|
+
|
|
17
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
18
|
+
attr_reader :data
|
|
19
|
+
|
|
20
|
+
sig { params(id: String).void }
|
|
21
|
+
def initialize(id)
|
|
22
|
+
@id = id
|
|
23
|
+
@root_id = T.let(nil, T.nilable(String))
|
|
24
|
+
@components = T.let({}, T::Hash[String, Component])
|
|
25
|
+
@data = T.let({}, T::Hash[String, T.untyped])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(root_id: String, components: T::Array[Component]).void }
|
|
29
|
+
def set_components(root_id, components)
|
|
30
|
+
@root_id = root_id
|
|
31
|
+
components.each { |c| @components[c.id] = c }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(updates: T::Array[DataUpdate]).void }
|
|
35
|
+
def apply_data_updates(updates)
|
|
36
|
+
updates.each do |update|
|
|
37
|
+
@data[update.path] = entries_to_hash(update.entries)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(path: String).returns(T.untyped) }
|
|
42
|
+
def get_data(path)
|
|
43
|
+
@data[path]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(String) }
|
|
47
|
+
def to_json
|
|
48
|
+
@data.to_json
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
sig { params(entries: T::Array[DataValue]).returns(T::Hash[String, T.untyped]) }
|
|
54
|
+
def entries_to_hash(entries)
|
|
55
|
+
entries.to_h do |entry|
|
|
56
|
+
value = case entry
|
|
57
|
+
when StringValue then entry.string
|
|
58
|
+
when NumberValue then entry.number
|
|
59
|
+
when BooleanValue then entry.boolean
|
|
60
|
+
when ObjectValue then entry.entries # Already a hash
|
|
61
|
+
end
|
|
62
|
+
[entry.key, value]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module A2UI
|
|
5
|
+
class SurfaceManager
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { void }
|
|
9
|
+
def initialize
|
|
10
|
+
@surfaces = T.let({}, T::Hash[String, Surface])
|
|
11
|
+
@generator = T.let(UIGenerator.new, UIGenerator)
|
|
12
|
+
@updater = T.let(UIUpdater.new, UIUpdater)
|
|
13
|
+
@action_handler = T.let(ActionHandler.new, ActionHandler)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(surface_id: String, request: String, data: String).returns(Surface) }
|
|
17
|
+
def create(surface_id:, request:, data: '{}')
|
|
18
|
+
result = @generator.call(
|
|
19
|
+
request: request,
|
|
20
|
+
surface_id: surface_id,
|
|
21
|
+
available_data: data
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
surface = Surface.new(surface_id)
|
|
25
|
+
surface.set_components(result.root_id, result.components)
|
|
26
|
+
surface.apply_data_updates(result.initial_data)
|
|
27
|
+
|
|
28
|
+
@surfaces[surface_id] = surface
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(surface_id: String, request: String).returns(T.untyped) }
|
|
32
|
+
def update(surface_id:, request:)
|
|
33
|
+
surface = @surfaces.fetch(surface_id)
|
|
34
|
+
|
|
35
|
+
@updater.call(
|
|
36
|
+
request: request,
|
|
37
|
+
surface_id: surface_id,
|
|
38
|
+
current_components: surface.components.values,
|
|
39
|
+
current_data: surface.to_json
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(action: UserAction, business_rules: String).returns(T.untyped) }
|
|
44
|
+
def handle_action(action:, business_rules: '')
|
|
45
|
+
surface = @surfaces.fetch(action.surface_id)
|
|
46
|
+
|
|
47
|
+
@action_handler.call(
|
|
48
|
+
action: action,
|
|
49
|
+
current_data: surface.to_json,
|
|
50
|
+
business_rules: business_rules
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { params(surface_id: String).returns(T.nilable(Surface)) }
|
|
55
|
+
def get(surface_id)
|
|
56
|
+
@surfaces[surface_id]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { params(surface_id: String).void }
|
|
60
|
+
def delete(surface_id)
|
|
61
|
+
@surfaces.delete(surface_id)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module A2UI
|
|
5
|
+
class UIGenerator < DSPy::Module
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { void }
|
|
9
|
+
def initialize
|
|
10
|
+
super
|
|
11
|
+
@predictor = DSPy::ChainOfThought.new(GenerateUI)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sig do
|
|
15
|
+
params(input_values: T.untyped).returns(T.untyped)
|
|
16
|
+
end
|
|
17
|
+
def forward(**input_values)
|
|
18
|
+
@predictor.call(
|
|
19
|
+
request: input_values.fetch(:request),
|
|
20
|
+
surface_id: input_values.fetch(:surface_id),
|
|
21
|
+
available_data: input_values.fetch(:available_data, '{}')
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module A2UI
|
|
5
|
+
class UIUpdater < DSPy::Module
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { void }
|
|
9
|
+
def initialize
|
|
10
|
+
super
|
|
11
|
+
@predictor = DSPy::ChainOfThought.new(UpdateUI)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sig do
|
|
15
|
+
params(input_values: T.untyped).returns(T.untyped)
|
|
16
|
+
end
|
|
17
|
+
def forward(**input_values)
|
|
18
|
+
@predictor.call(
|
|
19
|
+
request: input_values.fetch(:request),
|
|
20
|
+
surface_id: input_values.fetch(:surface_id),
|
|
21
|
+
current_components: input_values.fetch(:current_components, []),
|
|
22
|
+
current_data: input_values.fetch(:current_data, '{}')
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/a2ui/modules.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'dspy'
|
|
5
|
+
require_relative 'signatures'
|
|
6
|
+
|
|
7
|
+
# DSPy modules
|
|
8
|
+
require_relative 'modules/ui_generator'
|
|
9
|
+
require_relative 'modules/ui_updater'
|
|
10
|
+
require_relative 'modules/action_handler'
|
|
11
|
+
require_relative 'modules/data_validator'
|
|
12
|
+
require_relative 'modules/input_parser'
|
|
13
|
+
require_relative 'modules/layout_adapter'
|
|
14
|
+
|
|
15
|
+
# State management
|
|
16
|
+
require_relative 'modules/surface'
|
|
17
|
+
require_relative 'modules/surface_manager'
|