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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openui/rails/stream/bridge"
4
+
5
+ module OpenUI
6
+ module Rails
7
+ module Adapters
8
+ # Optional RubyLLM adapter. It owns RubyLLM-specific event normalization
9
+ # and delegates sequencing, coalescing and size limits to the Stream
10
+ # bridge, so a host keeps using RubyLLM's own Rails streaming pattern
11
+ # (`chat.complete { |chunk| ... }` in a job) instead of replacing it.
12
+ #
13
+ # `ruby_llm` is never required by the Gem and no `RubyLLM` constant is
14
+ # referenced: failures are classified by class *name*, so this unit loads
15
+ # and is tested without the dependency installed. The supported RubyLLM
16
+ # version is pinned in the README compatibility matrix.
17
+ module RubyLLM
18
+ STRATEGIES = %i[complete ask].freeze
19
+
20
+ class << self
21
+ # Stream a RubyLLM completion, yielding ordered {Stream::Envelope}s.
22
+ #
23
+ # @param chat [#complete, #ask] a RubyLLM chat (or a duck type)
24
+ # @param question [String] the user prompt
25
+ # @param surface_id [String, #to_s] the target surface
26
+ # @param strategy [Symbol] :complete (default) or :ask
27
+ # @yieldparam envelope [Stream::Envelope]
28
+ # @return [Stream::Bridge::Writer] for inspection in tests
29
+ def stream(chat:, question:, surface_id:, message_id: nil, library_version: nil,
30
+ instruction: nil, strategy: :complete,
31
+ configuration: OpenUI::Rails.configuration, &block)
32
+ raise ConfigurationError, "surface_id is required" if surface_id.nil?
33
+ raise ConfigurationError, "a block is required" unless block
34
+ unless STRATEGIES.include?(strategy)
35
+ raise ConfigurationError, "unsupported strategy #{strategy.inspect}; expected one of #{STRATEGIES.inspect}"
36
+ end
37
+
38
+ instruction ||= PromptBuilder.load(configuration).system_instruction
39
+ library_version ||= configuration.library_fingerprint
40
+ chat = apply_instruction(chat, instruction)
41
+
42
+ writer = Stream::Bridge.new(
43
+ message_id: message_id || surface_id.to_s,
44
+ library_version: library_version,
45
+ configuration: configuration
46
+ ).writer
47
+
48
+ begin
49
+ consumer_failed = false
50
+ deliver = lambda do |envelope|
51
+ begin
52
+ block.call(envelope)
53
+ rescue StandardError
54
+ consumer_failed = true
55
+ raise
56
+ end
57
+ end
58
+ consume(chat, question, strategy) do |chunk|
59
+ text = normalize_chunk(chunk)
60
+ next if text.nil?
61
+
62
+ writer.push(text).each(&deliver)
63
+ end
64
+ writer.finish.each(&deliver)
65
+ rescue StandardError => e
66
+ raise if consumer_failed
67
+
68
+ envelope = writer.error_for(e, code: error_code_for(e))
69
+ block.call(envelope) if envelope
70
+ end
71
+
72
+ writer
73
+ end
74
+
75
+ private
76
+
77
+ def apply_instruction(chat, instruction)
78
+ return chat unless chat.respond_to?(:with_instructions)
79
+
80
+ chat.with_instructions(instruction)
81
+ end
82
+
83
+ def consume(chat, question, strategy, &block)
84
+ case strategy
85
+ when :complete
86
+ # RubyLLM::Chat#complete only accepts a block. A user message is
87
+ # added explicitly; #ask is the convenient alternative strategy.
88
+ chat.add_message(role: :user, content: question) unless question.nil?
89
+ chat.complete(&block)
90
+ when :ask then chat.ask(question, &block)
91
+ end
92
+ end
93
+
94
+ def normalize_chunk(chunk)
95
+ return chunk if chunk.is_a?(String)
96
+ return nil unless chunk.respond_to?(:content)
97
+
98
+ content = chunk.content
99
+ content.nil? ? nil : content.to_s
100
+ end
101
+
102
+ def error_code_for(error)
103
+ name = error.class.name
104
+ return "stream_cancelled" if name == "RubyLLM::CancelledError"
105
+ return "provider_rejected" if name&.start_with?("RubyLLM::")
106
+
107
+ "stream_failed"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ # Global, process-wide configuration for the gem.
6
+ #
7
+ # OpenUI::Rails.configure do |config|
8
+ # config.transport = :turbo
9
+ # config.library_spec_path = Rails.root.join("config/openui/library.spec.json")
10
+ # config.runtime_assets = :self_hosted
11
+ # end
12
+ class Configuration
13
+ # Transport names accepted by the host preference setting.
14
+ TRANSPORTS = %i[turbo action_cable none].freeze
15
+
16
+ # Runtime asset strategies supported by the gem.
17
+ ASSET_STRATEGIES = %i[self_hosted external].freeze
18
+
19
+ # Maximum response size passed through the stream bridge, in bytes.
20
+ DEFAULT_STREAM_BYTE_LIMIT = 1_048_576 # 1 MiB of OpenUI Lang per message
21
+
22
+ # Buffer size before the bridge emits a delta, in bytes.
23
+ DEFAULT_PENDING_BYTE_LIMIT = 32_768 # flush threshold while streaming
24
+
25
+ # Lifetime of an action idempotency claim, in seconds.
26
+ DEFAULT_IDEMPOTENCY_TTL = 15 * 60 # seconds
27
+
28
+ # Default engine endpoint for action submissions.
29
+ DEFAULT_ACTIONS_ENDPOINT = "/openui-rails/actions"
30
+
31
+ attr_reader :transport, :runtime_assets
32
+ attr_accessor :library_spec_path, :prompt_path, :library_name,
33
+ :actions_endpoint, :surface_id_param,
34
+ :stream_byte_limit, :pending_byte_limit, :idempotency_ttl,
35
+ :authorize_action, :on_error, :action_registry, :surface_resolver, :actor_resolver,
36
+ :runtime_version, :runtime_url, :runtime_integrity, :local_runtime_path,
37
+ :idempotency_scope, :runtime_stylesheet_url, :runtime_stylesheet_integrity
38
+
39
+ # Create the default integration settings.
40
+ #
41
+ # @return [Configuration]
42
+ def initialize
43
+ @transport = :turbo
44
+ @runtime_assets = :self_hosted
45
+ @library_spec_path = nil
46
+ @prompt_path = nil
47
+ @library_name = :chat
48
+ @actions_endpoint = DEFAULT_ACTIONS_ENDPOINT
49
+ @surface_id_param = :surface_id
50
+ @stream_byte_limit = DEFAULT_STREAM_BYTE_LIMIT
51
+ @pending_byte_limit = DEFAULT_PENDING_BYTE_LIMIT
52
+ @idempotency_ttl = DEFAULT_IDEMPOTENCY_TTL
53
+ @idempotency_store = nil
54
+ @authorize_action = nil
55
+ @on_error = nil
56
+ @action_registry = nil
57
+ @surface_resolver = nil
58
+ @actor_resolver = nil
59
+ @runtime_version = nil
60
+ @runtime_url = nil
61
+ @runtime_integrity = nil
62
+ @local_runtime_path = "/openui"
63
+ end
64
+
65
+ # Set the preferred host stream transport.
66
+ #
67
+ # @param value [Symbol] `:turbo`, `:action_cable`, or `:none`
68
+ #
69
+ # @return [Symbol]
70
+ def transport=(value)
71
+ value = value.to_sym if value.respond_to?(:to_sym)
72
+ unless TRANSPORTS.include?(value)
73
+ raise ConfigurationError, "unsupported transport #{value.inspect}; expected one of #{TRANSPORTS.inspect}"
74
+ end
75
+
76
+ @transport = value
77
+ end
78
+
79
+ # Choose self-hosted or externally served runtime assets.
80
+ #
81
+ # @param value [Symbol] `:self_hosted` or `:external`
82
+ #
83
+ # @return [Symbol]
84
+ def runtime_assets=(value)
85
+ value = value.to_sym if value.respond_to?(:to_sym)
86
+ unless ASSET_STRATEGIES.include?(value)
87
+ raise ConfigurationError,
88
+ "unsupported runtime_assets #{value.inspect}; expected one of #{ASSET_STRATEGIES.inspect}"
89
+ end
90
+
91
+ @runtime_assets = value
92
+ end
93
+
94
+ # Set the store used to claim and record action idempotency keys.
95
+ # A shared store must implement the atomic contract described in the
96
+ # configuration guide.
97
+ #
98
+ # @param store [#claim, #record, nil]
99
+ #
100
+ # @return [#claim, #record]
101
+ def idempotency_store=(store)
102
+ unless store.nil? || (store.respond_to?(:claim) && store.respond_to?(:record))
103
+ raise ConfigurationError, "idempotency_store must respond to #claim and #record"
104
+ end
105
+
106
+ @idempotency_store = store
107
+ end
108
+
109
+ # Set the manifest path and clear the cached manifest.
110
+ #
111
+ # @param path [String, Pathname, nil]
112
+ #
113
+ # @return [String, Pathname, nil]
114
+ def library_spec_path=(path)
115
+ @library_spec_path = path
116
+ reset!
117
+ end
118
+
119
+ # Set a callable that returns a stable actor and tenant idempotency scope.
120
+ #
121
+ # @param callable [#call, nil] receives an Actions::Invocation
122
+ #
123
+ # @return [#call, nil]
124
+ def idempotency_scope=(callable)
125
+ unless callable.nil? || callable.respond_to?(:call)
126
+ raise ConfigurationError, "idempotency_scope must respond to #call"
127
+ end
128
+
129
+ @idempotency_scope = callable
130
+ end
131
+
132
+ # Set the global authorizer used when an action has no local authorizer.
133
+ #
134
+ # @param callable [#call, nil] receives an Actions::Invocation
135
+ #
136
+ # @return [#call, nil]
137
+ def authorize_action=(callable)
138
+ unless callable.nil? || callable.respond_to?(:call)
139
+ raise ConfigurationError, "authorize_action must respond to #call"
140
+ end
141
+
142
+ @authorize_action = callable
143
+ end
144
+
145
+ # Set the resolver that loads a surface inside the actor's permitted scope.
146
+ #
147
+ # @param callable [#call, nil] receives surface_id, actor, and context
148
+ #
149
+ # @return [#call, nil]
150
+ def surface_resolver=(callable)
151
+ unless callable.nil? || callable.respond_to?(:call)
152
+ raise ConfigurationError, "surface_resolver must respond to #call"
153
+ end
154
+
155
+ @surface_resolver = callable
156
+ end
157
+
158
+ # Set the callable that resolves the current actor from the engine controller.
159
+ #
160
+ # @param callable [#call, nil] receives the engine controller
161
+ #
162
+ # @return [#call, nil]
163
+ def actor_resolver=(callable)
164
+ unless callable.nil? || callable.respond_to?(:call)
165
+ raise ConfigurationError, "actor_resolver must respond to #call"
166
+ end
167
+
168
+ @actor_resolver = callable
169
+ end
170
+
171
+ # Return the configured store, creating a process-local memory store by default.
172
+ #
173
+ # @return [#claim, #record]
174
+ def idempotency_store
175
+ @idempotency_store ||= Actions::Idempotency::MemoryStore.new
176
+ end
177
+
178
+ # The registry of callable actions. Lazily created so require order does
179
+ # not matter and host apps can replace it if they wish.
180
+ #
181
+ # @return [Actions::Registry]
182
+ def action_registry
183
+ @action_registry ||= Actions::Registry.new
184
+ end
185
+
186
+ # Fingerprint of the pinned library manifest, or nil when unconfigured.
187
+ #
188
+ # @return [String, nil]
189
+ def library_fingerprint
190
+ library_manifest&.fingerprint
191
+ end
192
+
193
+ # Memoized manifest for the configured `library_spec_path`.
194
+ #
195
+ # @return [LibraryManifest, nil]
196
+ def library_manifest
197
+ return @library_manifest if defined?(@library_manifest) && @library_manifest
198
+
199
+ path = library_spec_path
200
+ return nil if path.nil?
201
+
202
+ @library_manifest = LibraryManifest.load(path)
203
+ end
204
+
205
+ # Drop memoized derived state (used after config changes and in tests).
206
+ # The action registry and idempotency store are runtime state and are
207
+ # preserved so re-configuring does not silently unregister actions.
208
+ #
209
+ # @return [self]
210
+ def reset!
211
+ remove_instance_variable(:@library_manifest) if defined?(@library_manifest)
212
+ self
213
+ end
214
+
215
+ # Explicit host readiness check. Not run during boot: the generator and
216
+ # runtime-fetch task must work before library files have been created.
217
+ #
218
+ # @return [true]
219
+ #
220
+ # @raise [ConfigurationError, ManifestError] when required settings or files are invalid
221
+ def validate!
222
+ raise ConfigurationError, "library_spec_path is not configured" if library_spec_path.nil?
223
+
224
+ if runtime_assets == :external && (runtime_url.nil? || runtime_url.empty?)
225
+ raise ConfigurationError, "runtime_assets :external requires an explicit, version-pinned runtime_url"
226
+ end
227
+
228
+ unless pending_byte_limit.is_a?(Integer) && stream_byte_limit.is_a?(Integer) &&
229
+ pending_byte_limit > 0 && stream_byte_limit > 0
230
+ raise ConfigurationError, "stream byte limits must be positive"
231
+ end
232
+
233
+ if pending_byte_limit > stream_byte_limit
234
+ raise ConfigurationError, "pending_byte_limit must not exceed stream_byte_limit"
235
+ end
236
+
237
+ unless idempotency_ttl.is_a?(Numeric) && idempotency_ttl.finite? && idempotency_ttl > 0
238
+ raise ConfigurationError, "idempotency_ttl must be positive"
239
+ end
240
+
241
+ reset!
242
+ library_manifest
243
+ PromptBuilder.load(self) if prompt_path
244
+ true
245
+ end
246
+ end
247
+
248
+ class << self
249
+ # Return the process-wide OpenUI Rails configuration.
250
+ #
251
+ # @return [Configuration]
252
+ def configuration
253
+ @configuration ||= Configuration.new
254
+ end
255
+
256
+ # Configure the process-wide integration and clear derived manifest state.
257
+ #
258
+ # @yieldparam config [Configuration]
259
+ #
260
+ # @return [Configuration]
261
+ def configure
262
+ yield configuration
263
+ configuration.reset!
264
+ configuration
265
+ end
266
+
267
+ # Discard the current configuration object. Primarily useful in tests.
268
+ #
269
+ # @return [nil]
270
+ def reset_configuration!
271
+ @configuration = nil
272
+ end
273
+
274
+ # Shorthand for the action registry.
275
+ #
276
+ # @return [Actions::Registry]
277
+ def actions
278
+ configuration.action_registry
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_support/inflector"
5
+ require "rails/engine"
6
+
7
+ # Register the acronym before the engine class is defined: `isolate_namespace`
8
+ # computes the routing module scope with `underscore` at class-definition time,
9
+ # so it must already know that `OpenUI` is `openui` (not `open_ui`).
10
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
11
+ inflect.acronym "OpenUI"
12
+ end
13
+
14
+ module OpenUI
15
+ module Rails
16
+ class Engine < ::Rails::Engine
17
+ isolate_namespace OpenUI::Rails
18
+
19
+ # Inside `module OpenUI`, a bare `Rails` resolves to `OpenUI::Rails`, so
20
+ # every reference to the framework must be written `::Rails`.
21
+ #
22
+ # Zeitwerk would infer `Openui` from the `openui` path segment; the
23
+ # acronym must be declared explicitly before the main autoloader is set
24
+ # up, otherwise constants such as `OpenUI::Rails::ActionsController`
25
+ # cannot be resolved.
26
+ initializer "openui_rails.inflections", before: :setup_main_autoloader do
27
+ ::Rails.autoloaders.main.inflector.inflect("openui" => "OpenUI")
28
+ end
29
+
30
+ # Expose the surface helper to every host view.
31
+ initializer "openui_rails.view_helper" do
32
+ ActiveSupport.on_load(:action_view) do
33
+ include OpenUI::Rails::ViewHelper
34
+ end
35
+ end
36
+
37
+ # Make the Stimulus controller reachable by Propshaft/Sprockets/importmap.
38
+ initializer "openui_rails.assets" do |app|
39
+ next unless app.config.respond_to?(:assets)
40
+
41
+ assets = app.config.assets
42
+ assets.paths << root.join("app/javascript") if assets.respond_to?(:paths)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ # Base class for every error raised by openui-rails. Rescuing
6
+ # `OpenUI::Rails::Error` rescues the whole taxonomy.
7
+ class Error < StandardError; end
8
+
9
+ # Raised when configuration is invalid or an unsupported option is set.
10
+ class ConfigurationError < Error; end
11
+
12
+ # Raised when a library manifest cannot be loaded, parsed or validated.
13
+ class ManifestError < Error; end
14
+
15
+ # Raised when a browser-rendered response targets a library version that
16
+ # does not match the pinned library fingerprint.
17
+ class LibraryVersionError < Error; end
18
+
19
+ # Raised when an action name is not present in the registry.
20
+ class UnknownActionError < Error; end
21
+
22
+ # Raised when the host authorizer denies an action.
23
+ class UnauthorizedActionError < Error; end
24
+
25
+ # Raised when an action payload fails schema/presence validation.
26
+ class InvalidActionError < Error; end
27
+
28
+ # Raised when a submitted action targets a surface revision that is no
29
+ # longer current.
30
+ class StaleSurfaceError < Error; end
31
+
32
+ # Raised when an action submission replays a known idempotency key.
33
+ class ReplayError < Error; end
34
+
35
+ # Raised for malformed or unsafe stream payloads.
36
+ class StreamError < Error; end
37
+
38
+ # Raised when a caller requests an unsupported protocol/library version.
39
+ class UnsupportedVersionError < Error; end
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ # Internal canonical JSON copy. Never freezes caller-owned values or lets
6
+ # mutable input change a fingerprint after validation.
7
+ module JsonValue # :nodoc:
8
+ def self.copy(value, depth = 0)
9
+ raise ArgumentError, "JSON nesting exceeds 100 levels" if depth > 100
10
+
11
+ case value
12
+ when Hash
13
+ pairs = value.map do |key, entry|
14
+ raise ArgumentError, "JSON object keys must be strings or symbols" unless key.is_a?(String) || key.is_a?(Symbol)
15
+
16
+ [ key.to_s, entry ]
17
+ end
18
+ raise ArgumentError, "duplicate JSON object keys" unless pairs.map(&:first).uniq.size == pairs.size
19
+
20
+ pairs.sort_by(&:first).to_h.transform_values { |entry| copy(entry, depth + 1) }.freeze
21
+ when Array then value.map { |entry| copy(entry, depth + 1) }.freeze
22
+ when String then value.dup.freeze
23
+ when Integer, TrueClass, FalseClass, NilClass then value
24
+ when Float
25
+ raise ArgumentError, "JSON numbers must be finite" unless value.finite?
26
+
27
+ value
28
+ else raise ArgumentError, "unsupported JSON value #{value.class}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end