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.
- checksums.yaml +7 -0
- data/AUDIT.md +102 -0
- data/CHANGELOG.md +71 -0
- data/CODE_OF_CONDUCT.md +37 -0
- data/CONFIGURATION.md +79 -0
- data/CONTRIBUTING.md +66 -0
- data/LICENSE +22 -0
- data/README.md +471 -0
- data/RELEASING.md +90 -0
- data/SECURITY.md +48 -0
- data/THIRD_PARTY_NOTICES.md +45 -0
- data/UPGRADING.md +26 -0
- data/app/controllers/openui/rails/actions_controller.rb +77 -0
- data/app/controllers/openui/rails/application_controller.rb +10 -0
- data/app/javascript/openui/rails/openui_controller.js +347 -0
- data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
- data/config/routes.rb +5 -0
- data/lib/generators/openui/install/install_generator.rb +62 -0
- data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
- data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
- data/lib/openui/rails/actions/dispatcher.rb +194 -0
- data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
- data/lib/openui/rails/actions/invocation.rb +16 -0
- data/lib/openui/rails/actions/registry.rb +113 -0
- data/lib/openui/rails/actions/schema.rb +115 -0
- data/lib/openui/rails/actions/submission.rb +102 -0
- data/lib/openui/rails/actions.rb +18 -0
- data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
- data/lib/openui/rails/configuration.rb +282 -0
- data/lib/openui/rails/engine.rb +46 -0
- data/lib/openui/rails/errors.rb +41 -0
- data/lib/openui/rails/json_value.rb +33 -0
- data/lib/openui/rails/library_manifest.rb +234 -0
- data/lib/openui/rails/message.rb +139 -0
- data/lib/openui/rails/prompt_builder.rb +115 -0
- data/lib/openui/rails/runtime_asset.rb +65 -0
- data/lib/openui/rails/runtime_package.rb +74 -0
- data/lib/openui/rails/stream/accumulator.rb +86 -0
- data/lib/openui/rails/stream/bridge.rb +193 -0
- data/lib/openui/rails/stream/broadcaster.rb +106 -0
- data/lib/openui/rails/stream/envelope.rb +159 -0
- data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
- data/lib/openui/rails/stream/transports/memory.rb +33 -0
- data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
- data/lib/openui/rails/stream/transports.rb +41 -0
- data/lib/openui/rails/surface.rb +150 -0
- data/lib/openui/rails/version.rb +5 -0
- data/lib/openui/rails/view_helper.rb +56 -0
- data/lib/openui/rails.rb +33 -0
- data/lib/tasks/openui.rake +16 -0
- 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,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,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`.
|