openui-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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/AUDIT.md +102 -0
  3. data/CHANGELOG.md +71 -0
  4. data/CODE_OF_CONDUCT.md +37 -0
  5. data/CONFIGURATION.md +79 -0
  6. data/CONTRIBUTING.md +66 -0
  7. data/LICENSE +22 -0
  8. data/README.md +471 -0
  9. data/RELEASING.md +90 -0
  10. data/SECURITY.md +48 -0
  11. data/THIRD_PARTY_NOTICES.md +45 -0
  12. data/UPGRADING.md +26 -0
  13. data/app/controllers/openui/rails/actions_controller.rb +77 -0
  14. data/app/controllers/openui/rails/application_controller.rb +10 -0
  15. data/app/javascript/openui/rails/openui_controller.js +347 -0
  16. data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
  17. data/config/routes.rb +5 -0
  18. data/lib/generators/openui/install/install_generator.rb +62 -0
  19. data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
  20. data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
  21. data/lib/openui/rails/actions/dispatcher.rb +194 -0
  22. data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
  23. data/lib/openui/rails/actions/invocation.rb +16 -0
  24. data/lib/openui/rails/actions/registry.rb +113 -0
  25. data/lib/openui/rails/actions/schema.rb +115 -0
  26. data/lib/openui/rails/actions/submission.rb +102 -0
  27. data/lib/openui/rails/actions.rb +18 -0
  28. data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
  29. data/lib/openui/rails/configuration.rb +282 -0
  30. data/lib/openui/rails/engine.rb +46 -0
  31. data/lib/openui/rails/errors.rb +41 -0
  32. data/lib/openui/rails/json_value.rb +33 -0
  33. data/lib/openui/rails/library_manifest.rb +234 -0
  34. data/lib/openui/rails/message.rb +139 -0
  35. data/lib/openui/rails/prompt_builder.rb +115 -0
  36. data/lib/openui/rails/runtime_asset.rb +65 -0
  37. data/lib/openui/rails/runtime_package.rb +74 -0
  38. data/lib/openui/rails/stream/accumulator.rb +86 -0
  39. data/lib/openui/rails/stream/bridge.rb +193 -0
  40. data/lib/openui/rails/stream/broadcaster.rb +106 -0
  41. data/lib/openui/rails/stream/envelope.rb +159 -0
  42. data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
  43. data/lib/openui/rails/stream/transports/memory.rb +33 -0
  44. data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
  45. data/lib/openui/rails/stream/transports.rb +41 -0
  46. data/lib/openui/rails/surface.rb +150 -0
  47. data/lib/openui/rails/version.rb +5 -0
  48. data/lib/openui/rails/view_helper.rb +56 -0
  49. data/lib/openui/rails.rb +33 -0
  50. data/lib/tasks/openui.rake +16 -0
  51. metadata +125 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openui/rails/stream/transports/memory"
4
+ require "openui/rails/stream/transports/action_cable"
5
+ require "openui/rails/stream/transports/turbo_streams"
6
+
7
+ module OpenUI
8
+ module Rails
9
+ module Stream
10
+ # Transport wrappers for {Broadcaster}. Each responds to
11
+ # `#broadcast(envelope)` and is intentionally thin: sequencing, coalescing
12
+ # and size limits live in {Bridge}, not here.
13
+ module Transports
14
+ module_function
15
+
16
+ # Build a transport from a configured name.
17
+ #
18
+ # @param name [Symbol] :action_cable, :turbo / :turbo_streams or :memory
19
+ #
20
+ # @param options [Hash] forwarded to the transport constructor
21
+ #
22
+ # @return [ActionCable, TurboStreams, Memory]
23
+ #
24
+ # @raise [ConfigurationError] when the name is unsupported
25
+ def build(name, **options)
26
+ case name.to_sym
27
+ when :action_cable
28
+ ActionCable.new(options.delete(:stream_name), **options)
29
+ when :turbo, :turbo_streams
30
+ TurboStreams.new(options.delete(:stream), **options)
31
+ when :memory
32
+ Memory.new(**options)
33
+ else
34
+ raise ConfigurationError,
35
+ "unsupported transport #{name.inspect}; expected one of [:action_cable, :turbo, :memory]"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ # Immutable description of one mounted OpenUI surface.
6
+ #
7
+ # The host Rails app owns persistence; the Gem only needs a stable,
8
+ # opaque identifier plus the current revision and the source to render.
9
+ # A surface can be built explicitly or coerced from a host record that
10
+ # exposes `id` (and, optionally, `version`, `library_version`, `response`,
11
+ # `context`, `message_id`).
12
+ class Surface
13
+ # Surface values copied from an explicit object, hash, or host record.
14
+ ATTRIBUTES = %i[id version library_version response context streaming message_id].freeze
15
+
16
+ attr_reader(*ATTRIBUTES)
17
+
18
+ # Coerce a host record, hash, or existing surface into a new Surface.
19
+ # Non-nil overrides replace values extracted from the input.
20
+ #
21
+ # @param value [Surface, Hash, #id, nil]
22
+ #
23
+ # @param overrides [Hash] surface attribute overrides
24
+ #
25
+ # @return [Surface]
26
+ def self.coerce(value = nil, **overrides)
27
+ attributes = extract(value).merge(overrides.compact)
28
+ new(**attributes)
29
+ end
30
+
31
+ def self.extract(value)
32
+ case value
33
+ when nil then {}
34
+ when Surface then value.to_h
35
+ when Hash
36
+ ATTRIBUTES.each_with_object({}) do |key, attributes|
37
+ if value.key?(key)
38
+ attributes[key] = value[key]
39
+ elsif value.key?(key.to_s)
40
+ attributes[key] = value[key.to_s]
41
+ end
42
+ end
43
+ else extract_record(value)
44
+ end
45
+ end
46
+ private_class_method :extract
47
+
48
+ def self.extract_record(record)
49
+ unless record.respond_to?(:id)
50
+ raise ArgumentError, "surface must respond to #id or be a Hash/#{name}"
51
+ end
52
+
53
+ ATTRIBUTES.each_with_object({ id: record.id }) do |attribute, memo|
54
+ memo[attribute] = record.public_send(attribute) if attribute != :id && record.respond_to?(attribute)
55
+ end
56
+ end
57
+ private_class_method :extract_record
58
+
59
+ # Build an immutable surface snapshot for the browser mount.
60
+ #
61
+ # @param id [String, #to_s] stable opaque identifier
62
+ #
63
+ # @param version [Integer, nil] non-negative revision used for stale-action checks
64
+ #
65
+ # @param library_version [String, nil] fingerprint of the browser library manifest
66
+ #
67
+ # @param response [String, nil] OpenUI Lang source rendered by the browser
68
+ #
69
+ # @param context [Object, nil] JSON-compatible initial state
70
+ #
71
+ # @param streaming [Boolean] whether a response is still streaming
72
+ #
73
+ # @param message_id [String, nil] stream identity; defaults to id
74
+ #
75
+ # @return [Surface]
76
+ def initialize(id:, version: 0, library_version: nil, response: nil, context: nil,
77
+ streaming: false, message_id: nil)
78
+ raise ArgumentError, "surface id is required" if id.nil? || id.to_s.empty?
79
+ unless version.nil? || (version.is_a?(Integer) && version >= 0)
80
+ raise ArgumentError, "surface version must be a non-negative Integer"
81
+ end
82
+ raise ArgumentError, "surface response must be a String" unless response.nil? || response.is_a?(String)
83
+
84
+ @id = id.to_s.dup.freeze
85
+ @version = version || 0
86
+ @library_version = library_version&.to_s&.dup&.freeze
87
+ @response = response&.dup&.freeze
88
+ @context = JsonValue.copy(context)
89
+ @streaming = streaming ? true : false
90
+ @message_id = (message_id&.to_s || @id).dup.freeze
91
+ freeze
92
+ end
93
+
94
+ # Whether the host says this surface is still receiving a response.
95
+ #
96
+ # @return [Boolean]
97
+ def streaming? = @streaming
98
+
99
+ # Return the surface fields as a symbol-keyed hash.
100
+ #
101
+ # @return [Hash]
102
+ def to_h
103
+ {
104
+ id: id, version: version, library_version: library_version,
105
+ response: response, context: context, streaming: streaming, message_id: message_id
106
+ }
107
+ end
108
+
109
+ # The flat `data:` hash for the Stimulus mount element. Keys use
110
+ # underscores so Rails renders them as `data-openui-*` attributes; nil
111
+ # values are omitted so the controller can rely on presence.
112
+ #
113
+ # Every value is deliberately a String. DOM data attributes are strings,
114
+ # and Rails only JSON-coerces *non-String* values. This path broke on
115
+ # Rails 8.0 with json >= 3.1 (ActiveSupport passes a `quirks_mode:`
116
+ # keyword the gem removed). Emitting strings keeps rendering independent
117
+ # of ActiveSupport's JSON encoder.
118
+ #
119
+ # @param stream [Boolean] mark this mount as streaming
120
+ #
121
+ # @param actions_endpoint [String] host engine action URL
122
+ #
123
+ # @param initial_state [Object, nil] JSON-compatible state override
124
+ #
125
+ # @return [Hash] HTML data attributes with string values
126
+ def to_mount_data(stream: false, actions_endpoint:, initial_state: nil)
127
+ {
128
+ controller: "openui",
129
+ openui_surface_id: id,
130
+ openui_message_id: message_id,
131
+ openui_version: version.to_s,
132
+ openui_library_version: library_version,
133
+ openui_streaming: (streaming? || stream) ? "true" : "false",
134
+ openui_actions_url: actions_endpoint,
135
+ openui_response: response,
136
+ openui_stream_byte_limit: OpenUI::Rails.configuration.stream_byte_limit.to_s,
137
+ openui_initial_state: encode_state(initial_state || context)
138
+ }.compact
139
+ end
140
+
141
+ private
142
+
143
+ def encode_state(state)
144
+ return nil if state.nil?
145
+
146
+ state.is_a?(String) ? state : JSON.generate(state)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,5 @@
1
+ module OpenUI
2
+ module Rails
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ # View helper that emits the escaped DOM mount the Stimulus controller
6
+ # connects to. It renders no OpenUI UI itself: the browser runtime owns
7
+ # rendering, Rails owns the opaque identifier and revision.
8
+ module ViewHelper
9
+ # Include once in the host layout's head. The pinned bundle requires its
10
+ # stylesheet; it is not injected by JavaScript or repeated per surface.
11
+ def openui_stylesheet_tag
12
+ stylesheet_link_tag RuntimeAsset.stylesheet_url,
13
+ integrity: RuntimeAsset.stylesheet_integrity, crossorigin: "anonymous"
14
+ end
15
+
16
+ # Render a mount element for a surface.
17
+ #
18
+ # @param surface [Surface, #id, nil] a Surface, a host record, or nil when
19
+ # `response`/`id` are supplied directly
20
+ # @param stream [Boolean] mark the surface as still streaming
21
+ # @param label [String, nil] accessible name; when given the mount is
22
+ # exposed as a `region` landmark so assistive tech can find it
23
+ # @param options [Hash] overrides forwarded to {Surface.coerce}
24
+ # @return [ActiveSupport::SafeBuffer]
25
+ def openui_surface(surface = nil, stream: false, dom_id: nil, initial_state: nil, label: nil, **options)
26
+ surface ||= options.delete(:surface)
27
+ surface = Surface.coerce(surface, **options)
28
+ fingerprint = OpenUI::Rails.configuration.library_fingerprint
29
+ if fingerprint && surface.library_version && fingerprint != surface.library_version
30
+ raise LibraryVersionError, "surface targets a different library; regenerate it with the configured library"
31
+ end
32
+ data = surface.to_mount_data(
33
+ stream: stream,
34
+ actions_endpoint: OpenUI::Rails.configuration.actions_endpoint,
35
+ initial_state: initial_state
36
+ ).merge(RuntimeAsset.mount_data)
37
+ data[:openui_library_fingerprint] = fingerprint if fingerprint
38
+
39
+ # `aria-busy` tells assistive tech the region is still being updated, so
40
+ # a streaming surface is not announced mid-flight. The controller keeps
41
+ # it in sync as envelopes complete.
42
+ aria = { busy: surface.streaming? || stream }
43
+ aria[:label] = label if label
44
+
45
+ tag.div(
46
+ "",
47
+ id: dom_id || "openui-surface-#{surface.id}",
48
+ class: "openui-surface",
49
+ role: label ? "region" : nil,
50
+ data: data,
51
+ aria: aria
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openui/rails/version"
4
+
5
+ module OpenUI
6
+ # Rails integration for Thesys OpenUI Lang.
7
+ #
8
+ # This is an *unofficial* integration: it does not port or fork the OpenUI
9
+ # parser/renderer, it wires the official, version-pinned JavaScript runtime
10
+ # into a Rails engine and keeps Rails authoritative for auth, tenancy and
11
+ # persistence.
12
+ module Rails
13
+ end
14
+ end
15
+
16
+ require "openui/rails/errors"
17
+ require "openui/rails/json_value"
18
+ require "openui/rails/library_manifest"
19
+ require "openui/rails/prompt_builder"
20
+ require "openui/rails/message"
21
+ require "openui/rails/stream/envelope"
22
+ require "openui/rails/stream/accumulator"
23
+ require "openui/rails/stream/bridge"
24
+ require "openui/rails/stream/broadcaster"
25
+ require "openui/rails/stream/transports"
26
+ require "openui/rails/actions"
27
+ require "openui/rails/adapters/ruby_llm"
28
+ require "openui/rails/runtime_asset"
29
+ require "openui/rails/runtime_package"
30
+ require "openui/rails/surface"
31
+ require "openui/rails/view_helper"
32
+ require "openui/rails/configuration"
33
+ require "openui/rails/engine"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :openui do
4
+ namespace :runtime do
5
+ desc "Vendor the checksum-locked OpenUI JS, CSS and license (requires npm)"
6
+ task :fetch, [ :destination ] => :environment do |_task, args|
7
+ configuration = OpenUI::Rails.configuration
8
+ destination = args[:destination] || ::Rails.root.join("public", configuration.local_runtime_path.delete_prefix("/"))
9
+ paths = OpenUI::Rails::RuntimePackage.fetch(
10
+ destination: destination, version: OpenUI::Rails::RuntimeAsset.version(configuration)
11
+ )
12
+ paths.each { |path| puts "Vendored #{path}" }
13
+ puts "Checksums verified; include <%= openui_stylesheet_tag %> in your layout."
14
+ end
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: openui-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andy Wang
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '8.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '8.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ description: |
34
+ Render model-generated, streaming, interactive OpenUI Lang UI inside an
35
+ existing Rails application without migrating the whole frontend to a
36
+ React/Next.js SPA. Reuses the official, version-pinned OpenUI JavaScript
37
+ runtime, keeps Rails authoritative for authentication, authorization and
38
+ persistence, and ships an optional RubyLLM adapter. This is an unofficial
39
+ integration and is not affiliated with, endorsed by, or maintained by
40
+ Thesys.
41
+ email:
42
+ - wangyaodi@gmail.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - AUDIT.md
48
+ - CHANGELOG.md
49
+ - CODE_OF_CONDUCT.md
50
+ - CONFIGURATION.md
51
+ - CONTRIBUTING.md
52
+ - LICENSE
53
+ - README.md
54
+ - RELEASING.md
55
+ - SECURITY.md
56
+ - THIRD_PARTY_NOTICES.md
57
+ - UPGRADING.md
58
+ - app/controllers/openui/rails/actions_controller.rb
59
+ - app/controllers/openui/rails/application_controller.rb
60
+ - app/javascript/openui/rails/openui_controller.js
61
+ - app/javascript/openui/rails/openui_envelope_controller.js
62
+ - config/routes.rb
63
+ - lib/generators/openui/install/install_generator.rb
64
+ - lib/generators/openui/install/templates/config/initializers/openui.rb.tt
65
+ - lib/generators/openui/install/templates/config/openui/README.md
66
+ - lib/openui/rails.rb
67
+ - lib/openui/rails/actions.rb
68
+ - lib/openui/rails/actions/dispatcher.rb
69
+ - lib/openui/rails/actions/idempotency/memory_store.rb
70
+ - lib/openui/rails/actions/invocation.rb
71
+ - lib/openui/rails/actions/registry.rb
72
+ - lib/openui/rails/actions/schema.rb
73
+ - lib/openui/rails/actions/submission.rb
74
+ - lib/openui/rails/adapters/ruby_llm.rb
75
+ - lib/openui/rails/configuration.rb
76
+ - lib/openui/rails/engine.rb
77
+ - lib/openui/rails/errors.rb
78
+ - lib/openui/rails/json_value.rb
79
+ - lib/openui/rails/library_manifest.rb
80
+ - lib/openui/rails/message.rb
81
+ - lib/openui/rails/prompt_builder.rb
82
+ - lib/openui/rails/runtime_asset.rb
83
+ - lib/openui/rails/runtime_package.rb
84
+ - lib/openui/rails/stream/accumulator.rb
85
+ - lib/openui/rails/stream/bridge.rb
86
+ - lib/openui/rails/stream/broadcaster.rb
87
+ - lib/openui/rails/stream/envelope.rb
88
+ - lib/openui/rails/stream/transports.rb
89
+ - lib/openui/rails/stream/transports/action_cable.rb
90
+ - lib/openui/rails/stream/transports/memory.rb
91
+ - lib/openui/rails/stream/transports/turbo_streams.rb
92
+ - lib/openui/rails/surface.rb
93
+ - lib/openui/rails/version.rb
94
+ - lib/openui/rails/view_helper.rb
95
+ - lib/tasks/openui.rake
96
+ homepage: https://github.com/yorzi/openui-rails
97
+ licenses:
98
+ - MIT
99
+ metadata:
100
+ source_code_uri: https://github.com/yorzi/openui-rails
101
+ changelog_uri: https://github.com/yorzi/openui-rails/blob/main/CHANGELOG.md
102
+ bug_tracker_uri: https://github.com/yorzi/openui-rails/issues
103
+ documentation_uri: https://openui-rails.wangyaodi.com
104
+ rubygems_mfa_required: 'true'
105
+ allowed_push_host: https://rubygems.org
106
+ post_install_message:
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: 3.2.0
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.5.22
122
+ signing_key:
123
+ specification_version: 4
124
+ summary: Unofficial Rails integration for Thesys OpenUI Lang.
125
+ test_files: []