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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ # Single allowlisted endpoint for surface actions. Both a component
6
+ # `onAction` and a browser `toolProvider` `Query()`/`Mutation()` call post
7
+ # here; the {Actions::Dispatcher} applies every gate (allowlist, schema,
8
+ # confirmation, staleness, authorization, idempotency) before any effect.
9
+ #
10
+ # Forged cross-site requests cannot pass Rails' CSRF protection, and a
11
+ # missing/invalid token is reported with the same structured shape as any
12
+ # other failed gate.
13
+ class ActionsController < ApplicationController
14
+ rescue_from ::ActionController::InvalidAuthenticityToken, with: :render_csrf_error
15
+ rescue_from OpenUI::Rails::Error, with: :render_domain_error
16
+
17
+ # Reads the JSON/form body only (not path params), so the documented
18
+ # top-level `action` key cannot collide with the Rails route's own
19
+ # `action` param. Only known keys are used; values are validated again by
20
+ # Submission and the action schema.
21
+ PAYLOAD_KEYS = %w[action args surface_id version idempotency_key confirmed].freeze
22
+
23
+ def create
24
+ result = dispatcher.call(payload, actor: actor)
25
+
26
+ render json: { result: result.to_h }, status: :ok
27
+ end
28
+
29
+ private
30
+
31
+ def dispatcher
32
+ Actions::Dispatcher.from_configuration
33
+ end
34
+
35
+ def payload
36
+ request.request_parameters.slice(*PAYLOAD_KEYS)
37
+ end
38
+
39
+ def actor
40
+ resolver = OpenUI::Rails.configuration.actor_resolver
41
+ return resolver.call(self) if resolver
42
+
43
+ current_user if respond_to?(:current_user, true)
44
+ end
45
+
46
+ def render_csrf_error(_error)
47
+ render json: { error: { code: "csrf_failed", message: "invalid authenticity token" } },
48
+ status: :unprocessable_content
49
+ end
50
+
51
+ def render_domain_error(error)
52
+ render json: { error: { code: error_code(error), message: error.message } },
53
+ status: error_status(error)
54
+ end
55
+
56
+ def error_code(error)
57
+ case error
58
+ when UnknownActionError then "unknown_action"
59
+ when UnauthorizedActionError then "unauthorized"
60
+ when StaleSurfaceError then "stale_surface"
61
+ when ReplayError then "replay"
62
+ when InvalidActionError then "invalid_action"
63
+ else "error"
64
+ end
65
+ end
66
+
67
+ def error_status(error)
68
+ case error
69
+ when UnknownActionError then :not_found
70
+ when UnauthorizedActionError then :forbidden
71
+ when StaleSurfaceError, ReplayError then :conflict
72
+ else :unprocessable_content
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ # Preserve the host's authentication filters and current-user helpers.
6
+ class ApplicationController < ::ApplicationController
7
+ protect_from_forgery with: :exception
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,347 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The pinned runtime is fetched once per page and shared by every surface.
4
+ // A rejected promise is evicted so a later mount can retry.
5
+ const runtimePromises = new Map()
6
+
7
+ function loadRuntime(url, integrity) {
8
+ if (!runtimePromises.has(url)) {
9
+ const promise = new Promise((resolve, reject) => {
10
+ const script = document.createElement("script")
11
+ script.src = url
12
+ script.async = true
13
+ script.crossOrigin = "anonymous"
14
+ if (integrity) script.integrity = integrity
15
+ script.addEventListener("load", () => {
16
+ if (!window.__OpenUI) {
17
+ reject(new Error(`OpenUI runtime at ${url} did not expose window.__OpenUI`))
18
+ return
19
+ }
20
+ resolve(window.__OpenUI)
21
+ })
22
+ script.addEventListener("error", () =>
23
+ reject(new Error(`Failed to load the OpenUI runtime from ${url}`))
24
+ )
25
+ document.head.appendChild(script)
26
+ })
27
+ promise.catch(() => runtimePromises.delete(url))
28
+ runtimePromises.set(url, promise)
29
+ }
30
+ return runtimePromises.get(url)
31
+ }
32
+
33
+ /**
34
+ * Mounts the official OpenUI renderer against a server-owned surface.
35
+ *
36
+ * The element is a passive view: it renders the accumulated OpenUI Lang, routes
37
+ * every action/tool call to the single Rails endpoint, and never holds a
38
+ * credential. Streaming envelopes arrive as `openui:envelope` events and are
39
+ * applied in order, ignoring duplicates and requesting a snapshot on a gap.
40
+ */
41
+ export default class extends Controller {
42
+ connect() {
43
+ const connection = Symbol("openui-connection")
44
+ this.connection = connection
45
+ this.mountPoint = document.createElement("div")
46
+ this.element.appendChild(this.mountPoint)
47
+ this.abortController = new AbortController()
48
+ this.state ||= {
49
+ source: this.initialResponse,
50
+ streaming: this.streaming,
51
+ completed: this.element.dataset.openuiCompleted === "true",
52
+ nextSequence: Number(this.element.dataset.openuiNextSequence || 0),
53
+ needsSnapshot: this.element.dataset.openuiNeedsSnapshot === "true",
54
+ }
55
+
56
+ this.onEnvelope = (event) => this.applyEnvelope(event.detail)
57
+ // Listen on the document so any transport can deliver an envelope: a
58
+ // dispatch on this element bubbles up, and the envelope controller (used by
59
+ // the Turbo transport) re-emits from its own element. applyEnvelope ignores
60
+ // envelopes for other message ids, so a shared document listener is safe.
61
+ document.addEventListener("openui:envelope", this.onEnvelope)
62
+ this.onBeforeCache = () => {
63
+ this.element.dataset.openuiResponse = this.state.source
64
+ this.element.dataset.openuiStreaming = String(this.state.streaming)
65
+ this.element.dataset.openuiNextSequence = String(this.state.nextSequence)
66
+ this.element.dataset.openuiCompleted = String(this.state.completed)
67
+ this.element.dataset.openuiNeedsSnapshot = String(this.state.needsSnapshot)
68
+ this.disconnect()
69
+ }
70
+ document.addEventListener("turbo:before-cache", this.onBeforeCache)
71
+
72
+ this.syncBusy()
73
+ this.renderRuntime(connection)
74
+ }
75
+
76
+ disconnect() {
77
+ this.connection = null
78
+ document.removeEventListener("openui:envelope", this.onEnvelope)
79
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
80
+ this.abortController?.abort()
81
+ this.root?.unmount()
82
+ this.root = null
83
+ this.mountPoint?.remove()
84
+ this.mountPoint = null
85
+ }
86
+
87
+ async renderRuntime(connection) {
88
+ try {
89
+ const runtime = await loadRuntime(this.runtimeUrl, this.runtimeIntegrity)
90
+ await this.verifyLibrary(runtime.openuiChatLibrary)
91
+ if (connection !== this.connection || !this.element.isConnected || !this.mountPoint) return
92
+ this.runtime = runtime
93
+ this.render()
94
+ } catch (error) {
95
+ if (connection !== this.connection) return
96
+ this.state.streaming = false
97
+ this.syncBusy()
98
+ this.handleError(error)
99
+ }
100
+ }
101
+
102
+ render() {
103
+ this.syncBusy()
104
+ if (!this.runtime || !this.mountPoint || !this.connection) return
105
+ if (!this.withinByteLimit(this.state.source)) return
106
+ const { React, createRoot, Renderer, openuiChatLibrary } = this.runtime
107
+ if (!this.root) this.root = createRoot(this.mountPoint)
108
+ this.root.render(
109
+ React.createElement(Renderer, {
110
+ // The Renderer accepts either raw OpenUI Lang or a stored message that
111
+ // carries `]]>openui:` markers, so one accumulated field is enough.
112
+ response: this.state.source,
113
+ library: openuiChatLibrary,
114
+ isStreaming: this.state.streaming,
115
+ initialState: this.initialState,
116
+ // The official runtime emits { type, params }. It does not await this
117
+ // void callback; dispatchAction already emits a structured error.
118
+ onAction: (action) => { this.dispatchAction(action).catch(() => {}) },
119
+ onStateUpdate: (formState) => this.persistState(formState),
120
+ toolProvider: this.toolProvider(),
121
+ onError: (error) => this.handleError(error),
122
+ })
123
+ )
124
+ }
125
+
126
+ async verifyLibrary(library) {
127
+ const expected = this.element.dataset.openuiLibraryFingerprint
128
+ if (!expected) return
129
+ if (typeof library?.toSpec !== "function" || !window.crypto?.subtle) {
130
+ throw Object.assign(new Error("Cannot verify the configured OpenUI library"), { code: "library_version_mismatch" })
131
+ }
132
+ const canonical = (value) => {
133
+ if (Array.isArray(value)) return value.map(canonical)
134
+ if (value !== null && typeof value === "object") {
135
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [ key, canonical(value[key]) ]))
136
+ }
137
+ return value
138
+ }
139
+ const bytes = new TextEncoder().encode(JSON.stringify(canonical(library.toSpec())))
140
+ const digest = await window.crypto.subtle.digest("SHA-256", bytes)
141
+ const fingerprint = `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`
142
+ if (fingerprint !== expected) {
143
+ throw Object.assign(new Error("Runtime library differs from the configured prompt/spec library"), { code: "library_version_mismatch" })
144
+ }
145
+ }
146
+
147
+ // --- Streaming -----------------------------------------------------------
148
+
149
+ applyEnvelope(envelope) {
150
+ if (!envelope || envelope.message_id !== this.messageId) return
151
+ if (!Number.isSafeInteger(envelope.sequence) || envelope.sequence < 0 ||
152
+ (envelope.type === "openui.delta" && typeof envelope.delta !== "string") ||
153
+ (envelope.type === "openui.snapshot" && typeof envelope.response !== "string")) {
154
+ return this.handleError(Object.assign(new Error("Malformed stream envelope"), { code: "invalid_envelope" }))
155
+ }
156
+ if (envelope.library_version && this.libraryVersion && envelope.library_version !== this.libraryVersion) {
157
+ return this.handleError(Object.assign(new Error("OpenUI library version mismatch"), { code: "library_version_mismatch" }))
158
+ }
159
+ if (envelope.type !== "openui.snapshot") {
160
+ if (envelope.sequence < this.state.nextSequence || this.state.completed) return
161
+ if (this.state.needsSnapshot || envelope.sequence > this.state.nextSequence) return this.requestSnapshot()
162
+ }
163
+
164
+ switch (envelope.type) {
165
+ case "openui.delta":
166
+ return this.applyDelta(envelope)
167
+ case "openui.snapshot":
168
+ return this.applySnapshot(envelope)
169
+ case "openui.completed":
170
+ this.state.nextSequence += 1
171
+ this.state.streaming = false
172
+ this.state.completed = true
173
+ return this.render()
174
+ case "openui.error":
175
+ // A stream that ends in error is no longer busy, so assistive tech can
176
+ // read whatever rendered before the failure.
177
+ this.state.streaming = false
178
+ this.state.completed = true
179
+ this.state.nextSequence += 1
180
+ this.syncBusy()
181
+ return this.handleError(Object.assign(new Error(envelope.message || "stream error"), {
182
+ code: envelope.code,
183
+ }))
184
+ default:
185
+ return undefined
186
+ }
187
+ }
188
+
189
+ applyDelta(envelope) {
190
+ if (!this.withinByteLimit(this.state.source + envelope.delta)) return
191
+ this.state.source = this.state.source + envelope.delta
192
+ this.state.nextSequence += 1
193
+ this.state.streaming = envelope.streaming !== false
194
+ this.render()
195
+ }
196
+
197
+ applySnapshot(envelope) {
198
+ const minimum = this.state.needsSnapshot ? this.state.nextSequence - 1 : this.state.nextSequence
199
+ if (envelope.sequence < minimum || !this.withinByteLimit(envelope.response)) return
200
+ this.state.source = envelope.response
201
+ this.state.nextSequence = envelope.sequence + 1
202
+ this.state.needsSnapshot = false
203
+ this.state.streaming = envelope.streaming === true
204
+ this.state.completed = !this.state.streaming
205
+ this.render()
206
+ }
207
+
208
+ requestSnapshot() {
209
+ if (this.state.needsSnapshot) return
210
+ this.state.needsSnapshot = true
211
+ this.dispatch("snapshot-required", { surfaceId: this.surfaceId, sequence: this.state.nextSequence })
212
+ }
213
+
214
+ withinByteLimit(source) {
215
+ const limit = Number(this.element.dataset.openuiStreamByteLimit || 1048576)
216
+ if (new TextEncoder().encode(source).byteLength <= limit) return true
217
+ this.state.streaming = false
218
+ this.state.completed = true
219
+ this.syncBusy()
220
+ this.handleError(Object.assign(new Error("OpenUI stream exceeds byte limit"), { code: "stream_too_large" }))
221
+ return false
222
+ }
223
+
224
+ // --- Actions -------------------------------------------------------------
225
+
226
+ async dispatchAction(payload = {}) {
227
+ const body = {
228
+ action: payload.action ?? payload.name ?? payload.type,
229
+ args: payload.args ?? payload.params ?? {},
230
+ surface_id: this.surfaceId,
231
+ version: this.version,
232
+ idempotency_key: payload.idempotency_key ?? this.newIdempotencyKey(),
233
+ confirmed: payload.confirmed === true,
234
+ }
235
+
236
+ try {
237
+ const response = await fetch(this.actionsUrl, {
238
+ method: "POST",
239
+ credentials: "same-origin",
240
+ signal: this.abortController.signal,
241
+ headers: this.headers(),
242
+ body: JSON.stringify(body),
243
+ })
244
+ if (response.redirected) {
245
+ throw Object.assign(new Error("Action request was redirected; authentication may have expired"), { code: "unauthorized" })
246
+ }
247
+ if (!response.headers.get("Content-Type")?.includes("application/json")) {
248
+ throw Object.assign(new Error("Action endpoint did not return JSON"), { code: "invalid_response" })
249
+ }
250
+ let json
251
+ try {
252
+ json = await response.json()
253
+ } catch {
254
+ throw Object.assign(new Error("Action endpoint returned malformed JSON"), { code: "invalid_response" })
255
+ }
256
+ if (!response.ok) {
257
+ throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code })
258
+ }
259
+ const result = json?.result
260
+ if (!result || result.action !== body.action || result.surface_id !== body.surface_id || result.version !== body.version) {
261
+ throw Object.assign(new Error("Action response does not match the submitted action"), { code: "invalid_response" })
262
+ }
263
+ this.dispatch("action", { surfaceId: this.surfaceId, result })
264
+ return result
265
+ } catch (error) {
266
+ if (error.name !== "AbortError" || this.connection) this.handleError(error)
267
+ throw error
268
+ }
269
+ }
270
+
271
+ // Every browser `Query()`/`Mutation()` tool call is routed to the same
272
+ // server endpoint; the host registry decides what is permitted.
273
+ toolProvider() {
274
+ const reserved = new Set([ "callTool", "then", "__proto__", "constructor", "prototype", "toJSON" ])
275
+ return this.tools ||= new Proxy(
276
+ {},
277
+ {
278
+ has: (_target, name) => typeof name === "string" && !reserved.has(name),
279
+ get: (_target, name) => {
280
+ if (typeof name !== "string" || reserved.has(name)) return undefined
281
+ return (args) => this.dispatchAction({ action: name, args: args ?? {} }).then((result) => result.value)
282
+ },
283
+ }
284
+ )
285
+ }
286
+
287
+ // Persisting form state is the host's job; emit an event and let it decide.
288
+ persistState(formState) {
289
+ this.dispatch("state", { surfaceId: this.surfaceId, state: formState })
290
+ }
291
+
292
+ // --- Helpers -------------------------------------------------------------
293
+
294
+ // Mirror the streaming state onto `aria-busy` so a screen reader waits for a
295
+ // streaming region to settle instead of announcing each delta.
296
+ syncBusy() {
297
+ this.element.setAttribute("aria-busy", this.state.streaming ? "true" : "false")
298
+ }
299
+
300
+ handleError(error) {
301
+ // Renderer#onError receives an array, including [] after successful parses.
302
+ if (Array.isArray(error)) {
303
+ if (error.length === 0) return
304
+ error = Object.assign(new Error(error.map((entry) => entry.message || String(entry)).join("; ")),
305
+ { code: "renderer_error" })
306
+ }
307
+ console.error("[openui-rails]", error)
308
+ this.dispatch("error", { surfaceId: this.surfaceId, message: error?.message, code: error?.code })
309
+ }
310
+
311
+ headers() {
312
+ const token = document.querySelector("meta[name='csrf-token']")?.content
313
+ const headers = { "Content-Type": "application/json", Accept: "application/json" }
314
+ if (token) headers["X-CSRF-Token"] = token
315
+ return headers
316
+ }
317
+
318
+ newIdempotencyKey() {
319
+ if (window.crypto?.randomUUID) return window.crypto.randomUUID()
320
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`
321
+ }
322
+
323
+ dispatch(name, detail) {
324
+ this.element.dispatchEvent(new CustomEvent(`openui:${name}`, { bubbles: true, detail }))
325
+ }
326
+
327
+ get surfaceId() { return this.element.dataset.openuiSurfaceId }
328
+ get messageId() { return this.element.dataset.openuiMessageId || this.surfaceId }
329
+ get libraryVersion() { return this.element.dataset.openuiLibraryVersion }
330
+ get version() { return Number(this.element.dataset.openuiVersion || 0) }
331
+ get streaming() { return this.element.dataset.openuiStreaming === "true" }
332
+ get actionsUrl() { return this.element.dataset.openuiActionsUrl }
333
+ get runtimeUrl() { return this.element.dataset.openuiRuntimeUrl }
334
+ get runtimeIntegrity() { return this.element.dataset.openuiRuntimeIntegrity }
335
+
336
+ get initialResponse() { return this.element.dataset.openuiResponse || "" }
337
+
338
+ get initialState() {
339
+ const raw = this.element.dataset.openuiInitialState
340
+ if (!raw) return undefined
341
+ try {
342
+ return JSON.parse(raw)
343
+ } catch {
344
+ return undefined
345
+ }
346
+ }
347
+ }
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Bridges a server transport into the surface controller.
5
+ *
6
+ * Turbo Streams cannot dispatch a custom event directly, so the Turbo
7
+ * transport appends this element carrying a JSON envelope. On connect it
8
+ * re-emits the envelope as `openui:envelope`, which bubbles to the surface
9
+ * controller listening on `document`, then removes itself. No inline script
10
+ * and no CSP relaxation are involved.
11
+ *
12
+ * The same event shape is what an Action Cable subscriber should dispatch.
13
+ */
14
+ export default class extends Controller {
15
+ connect() {
16
+ const payload = this.element.dataset.openuiEnvelopePayload
17
+
18
+ if (payload) {
19
+ const detail = this.parse(payload)
20
+ if (detail) {
21
+ this.element.dispatchEvent(
22
+ new CustomEvent("openui:envelope", { bubbles: true, detail })
23
+ )
24
+ }
25
+ }
26
+
27
+ this.element.remove()
28
+ }
29
+
30
+ parse(payload) {
31
+ try {
32
+ return JSON.parse(payload)
33
+ } catch (error) {
34
+ console.error("[openui-rails] invalid envelope payload", error)
35
+ return null
36
+ }
37
+ }
38
+ }
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ OpenUI::Rails::Engine.routes.draw do
4
+ post "actions", to: "actions#create", as: :actions
5
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module OpenUI
7
+ # `bin/rails generate openui:install`
8
+ #
9
+ # Sets up a host app: an initializer, the `config/openui` library directory
10
+ # and the engine mount. It does not fetch the pinned JS runtime; that is a
11
+ # deliberate, reproducible step documented in the generated README and run
12
+ # with `bin/rails openui:runtime:fetch`.
13
+ class InstallGenerator < ::Rails::Generators::Base
14
+ # Thor derives the generator namespace from the class name with its own
15
+ # snake_case, which does not know the OpenUI acronym; set it explicitly so
16
+ # `rails generate openui:install` resolves.
17
+ namespace "openui:install"
18
+
19
+ source_root File.expand_path("templates", __dir__)
20
+
21
+ desc "Install openui-rails: configuration, library directory and engine mount."
22
+
23
+ def create_initializer
24
+ template "config/initializers/openui.rb.tt", "config/initializers/openui.rb"
25
+ end
26
+
27
+ def create_library_directory
28
+ template "config/openui/README.md", "config/openui/README.md"
29
+ end
30
+
31
+ def mount_engine
32
+ return unless routes_file?
33
+
34
+ route 'mount OpenUI::Rails::Engine => "/openui-rails"' unless already_mounted?
35
+ end
36
+
37
+ def show_next_steps
38
+ say ""
39
+ say "openui-rails installed.", :green
40
+ say ""
41
+ say "Next steps:"
42
+ say " 1. Generate the library prompt and spec with the pinned CLI:"
43
+ say " npx --yes @openuidev/cli@0.6.0 generate <entry> --out config/openui/library.prompt.txt --no-telemetry"
44
+ say " 2. Self-host the pinned runtime:"
45
+ say " bin/rails openui:runtime:fetch"
46
+ say " 3. Register actions and an authorizer (see config/initializers/openui.rb)."
47
+ say " 4. Pin/register the Stimulus controllers and include openui_stylesheet_tag in your layout."
48
+ say " The default bundle requires the exact openuiChatLibrary prompt/spec. See the gem README."
49
+ say ""
50
+ end
51
+
52
+ private
53
+
54
+ def routes_file?
55
+ File.exist?(File.join(destination_root, "config/routes.rb"))
56
+ end
57
+
58
+ def already_mounted?
59
+ File.read(File.join(destination_root, "config/routes.rb")).include?("OpenUI::Rails::Engine")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ OpenUI::Rails.configure do |config|
4
+ # Transport for incremental updates: :turbo, :action_cable or :none.
5
+ config.transport = :turbo
6
+
7
+ # The prompt and spec generated from the same, pinned library revision by the
8
+ # OpenUI CLI. See config/openui/README.md for the exact command.
9
+ config.library_spec_path = Rails.root.join("config/openui/library.spec.json")
10
+ config.prompt_path = Rails.root.join("config/openui/library.prompt.txt")
11
+
12
+ # :self_hosted (default) serves a vendored runtime; :external requires an
13
+ # explicit, exact-version runtime_url. See `bin/rails openui:runtime:fetch`.
14
+ config.runtime_assets = :self_hosted
15
+ # config.runtime_integrity = "sha384-..."
16
+
17
+ # Authorize every action/tool call. This fails closed: with no authorizer an
18
+ # action is denied. Return truthy to permit.
19
+ # config.authorize_action = ->(invocation) do
20
+ # OrderPolicy.new(invocation.actor, invocation.args["orderId"]).refresh?
21
+ # end
22
+
23
+ # Resolve a surface id to a record scoped to the actor/tenant. Returning nil
24
+ # is treated as "unknown or inaccessible surface" and denies the action.
25
+ # config.surface_resolver = ->(surface_id, actor:, **) { actor&.surfaces&.find_by(id: surface_id) }
26
+
27
+ # Expose the current actor to the actions endpoint. Defaults to `current_user`
28
+ # when the controller responds to it.
29
+ # config.actor_resolver = ->(controller) { controller.current_user }
30
+ end
@@ -0,0 +1,24 @@
1
+ # OpenUI library artifacts
2
+
3
+ This directory holds the **prompt** and **spec** generated from the same,
4
+ version-pinned component-library revision. Both must come from one revision so
5
+ the model prompt and the browser component library cannot drift.
6
+
7
+ The default browser bundle contains `openuiChatLibrary` only. For that bundle,
8
+ save the exact outputs of `window.__OpenUI.openuiChatLibrary.prompt()` and
9
+ `.toSpec()` as the prompt text and JSON spec. A custom library needs a matching
10
+ host-built runtime; the CLI does not change the default browser bundle.
11
+
12
+ For a custom library, use the pinned CLI (never `@latest`):
13
+
14
+ ```bash
15
+ npx --yes @openuidev/cli@0.6.0 generate ./src/library.ts --out config/openui/library.prompt.txt --no-telemetry
16
+ ```
17
+
18
+ The command writes `config/openui/library.prompt.txt` and
19
+ `config/openui/library.prompt.txt.spec.json`. Rename the spec to
20
+ `library.spec.json` (or point `library_spec_path` at the generated name) and
21
+ commit both files. `OpenUI::Rails` fingerprints the spec; a mismatched
22
+ fingerprint is a visible failure rather than a silent re-render.
23
+
24
+ Set `DO_NOT_TRACK=1` in CI as well as `--no-telemetry`.