openui-rails 0.4.0 → 0.9.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 +4 -4
- data/CHANGELOG.md +58 -1
- data/CONTRIBUTING.md +1 -1
- data/DEVELOPMENT.md +1 -1
- data/README.md +5 -4
- data/app/controllers/openui/rails/actions_controller.rb +13 -11
- data/app/javascript/openui/rails/openui_controller.js +47 -17
- data/examples/support_desk/README.md +7 -2
- data/examples/support_desk/app/javascript/support_desk.js +117 -32
- data/examples/support_desk/app/views/support_desk/conversations/show.html.erb +11 -1
- data/lib/openui/rails/actions/dispatcher.rb +21 -2
- data/lib/openui/rails/adapters/ruby_llm.rb +12 -0
- data/lib/openui/rails/configuration.rb +34 -2
- data/lib/openui/rails/engine.rb +4 -0
- data/lib/openui/rails/instrumentation.rb +36 -0
- data/lib/openui/rails/runtime_asset.rb +4 -3
- data/lib/openui/rails/stream/accumulator.rb +6 -2
- data/lib/openui/rails/stream/bridge.rb +31 -3
- data/lib/openui/rails/stream/broadcaster.rb +5 -1
- data/lib/openui/rails/version.rb +1 -1
- data/lib/openui/rails.rb +9 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 348e7ebd421344dd62f6f5449c09fa477de3374dc1803c51aa8bc1415ad0fcfc
|
|
4
|
+
data.tar.gz: 6b82e02f137a9bff26b5b8c9dfd3e6a6053593e5a75f66b87825486e2d667c67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f82e650f49c1b59912959c2e7d3d59d7b0c17088b0fb9dbcc9c97b235910645a5b026834cf509aa4b1915e55e9a2fafe20db57850b3a038dda144f2ce8572f2
|
|
7
|
+
data.tar.gz: 59e46f83487ce218dcd0780d89fc1bbe5a86dd6483d239cffd572effa327b17f130375761cea02ab472865ebd4fa5f8cc6c8587f58dad0e0266bc2a467cec25a
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,61 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.0] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- A Supported API reference page that names the Ruby methods, settings, tasks,
|
|
14
|
+
events, wire contracts, and runtime pin policy covered by 1.x compatibility.
|
|
15
|
+
|
|
16
|
+
### Deprecated
|
|
17
|
+
|
|
18
|
+
- `config.library_name` and `config.surface_id_param` have no effect and report
|
|
19
|
+
a deprecation through the new `OpenUI::Rails.deprecator`, registered in
|
|
20
|
+
`Rails.application.deprecators`. They will be removed in 1.0.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- The action endpoint returns HTTP 500 with code `error` and the fixed message
|
|
25
|
+
`action failed` for server-side gem errors, such as an unserializable result.
|
|
26
|
+
It previously returned 422 with the internal message. Details go to the log.
|
|
27
|
+
- `OpenUI::Rails::RuntimeAsset` is marked internal. Configure runtime assets
|
|
28
|
+
through `Configuration`.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- A response that exceeds `stream_byte_limit` no longer drains the provider.
|
|
33
|
+
`Bridge#envelopes`, `Broadcaster.broadcast`, and the RubyLLM adapter stop
|
|
34
|
+
reading after the `stream_too_large` frame. `Writer#terminated?` lets
|
|
35
|
+
callback-based providers do the same.
|
|
36
|
+
- A surface that reconnects while paused for a snapshot, including a Turbo
|
|
37
|
+
cache restoration, emits `openui:snapshot-required` again. It previously
|
|
38
|
+
dropped every later delta without telling the host.
|
|
39
|
+
- An exception raised by the `on_error` hook no longer suppresses the stream's
|
|
40
|
+
error frame or replaces the provider error, which left the surface busy.
|
|
41
|
+
- A surface without a stored `library_version` now checks stream frames against
|
|
42
|
+
the configured library fingerprint instead of accepting any library.
|
|
43
|
+
|
|
44
|
+
## [0.5.0] - 2026-10-21
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- Content-free `action.openui_rails` and `stream.openui_rails` notifications with
|
|
49
|
+
monotonic duration, fixed outcomes, replay status, and stream watermarks.
|
|
50
|
+
- Browser `openui:ready` and `openui:snapshot-applied` events for host-owned
|
|
51
|
+
fallback and recovery, plus stable runtime and network failure codes.
|
|
52
|
+
- Ordinary HTML fallback and explicit reload, scoped snapshot, and fresh sign-in
|
|
53
|
+
controls in the reference host, with keyboard and Turbo restoration checks.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- Late action responses cannot report into a reconnected surface. Failed
|
|
58
|
+
runtime scripts are removed so later mounts can retry loading.
|
|
59
|
+
- Unmounted renderer callbacks cannot send actions, tool requests, state, or
|
|
60
|
+
errors through a new connection. A late snapshot cannot dismiss a host's
|
|
61
|
+
authentication or permission failure.
|
|
62
|
+
- Browser failures use events without automatically logging raw error objects.
|
|
63
|
+
|
|
9
64
|
## [0.4.0] - 2026-10-20
|
|
10
65
|
|
|
11
66
|
### Added
|
|
@@ -133,7 +188,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
133
188
|
- Gem-scoped RubyGems Trusted Publishing through GitHub OIDC, with a protected
|
|
134
189
|
release environment and verified artifact handoff.
|
|
135
190
|
|
|
136
|
-
[Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.
|
|
191
|
+
[Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.9.0...main
|
|
192
|
+
[0.9.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.9.0
|
|
193
|
+
[0.5.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.5.0
|
|
137
194
|
[0.4.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.4.0
|
|
138
195
|
[0.3.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.3.0
|
|
139
196
|
[0.2.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.2.0
|
data/CONTRIBUTING.md
CHANGED
|
@@ -25,7 +25,7 @@ bundle exec bin/rails test # unit + integration tests
|
|
|
25
25
|
bundle exec bin/rails test:system # browser (system) tests, needs Chrome
|
|
26
26
|
bundle exec rubocop # style (rubocop-rails-omakase)
|
|
27
27
|
bundle exec gem build openui-rails.gemspec
|
|
28
|
-
bundle exec ruby bin/smoke-package openui-rails-0.
|
|
28
|
+
bundle exec ruby bin/smoke-package openui-rails-0.9.0.gem
|
|
29
29
|
bundle exec bundler-audit check --update
|
|
30
30
|
bundle exec ruby -Itest test/compatibility/ruby_llm_smoke.rb
|
|
31
31
|
```
|
data/DEVELOPMENT.md
CHANGED
|
@@ -17,7 +17,7 @@ authorizes a live call. Follow this loop for each feature:
|
|
|
17
17
|
```sh
|
|
18
18
|
bin/check-reference
|
|
19
19
|
bin/check-reference --runtime tmp/real-runtime
|
|
20
|
-
bin/check-reference --gem tmp/package/openui-rails-0.
|
|
20
|
+
bin/check-reference --gem tmp/package/openui-rails-0.9.0.gem --runtime tmp/real-runtime
|
|
21
21
|
# Focus one browser scenario:
|
|
22
22
|
bin/check-reference --runtime tmp/real-runtime --name test_a_lost_turbo_envelope_recovers_from_the_scoped_snapshot
|
|
23
23
|
```
|
data/README.md
CHANGED
|
@@ -32,7 +32,7 @@ The runtime-fetch task also needs npm. RubyLLM is optional.
|
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
34
34
|
# Gemfile
|
|
35
|
-
gem "openui-rails", "~> 0.
|
|
35
|
+
gem "openui-rails", "~> 0.9.0"
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
```sh
|
|
@@ -65,15 +65,16 @@ The documentation site contains the full guides and API reference:
|
|
|
65
65
|
- [RubyLLM](https://openui-rails.wangyaodi.com/ruby-llm/) and [production setup](https://openui-rails.wangyaodi.com/production/)
|
|
66
66
|
- [Upgrading](https://openui-rails.wangyaodi.com/upgrading/) and [API reference](https://openui-rails.wangyaodi.com/api-reference/)
|
|
67
67
|
|
|
68
|
-
Version `0.
|
|
69
|
-
See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-
|
|
68
|
+
Version `0.9.0` freezes the candidate API for 1.0 and fixes stream and recovery defects.
|
|
69
|
+
See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-9/) for the upgrade and
|
|
70
|
+
[Supported API](https://openui-rails.wangyaodi.com/supported-api/) for the compatibility boundary.
|
|
70
71
|
The API may change during `0.x`; see the
|
|
71
72
|
[changelog](CHANGELOG.md) and the proposed [path to 1.0](ROADMAP.md).
|
|
72
73
|
The site's stable channel follows the latest release; [/next/](https://openui-rails.wangyaodi.com/next/)
|
|
73
74
|
follows `main`.
|
|
74
75
|
|
|
75
76
|
For repository work, use [Contributing](CONTRIBUTING.md),
|
|
76
|
-
[Releasing](RELEASING.md), and the [0.
|
|
77
|
+
[Releasing](RELEASING.md), and the [0.5.0 release record](RELEASE_0_5.md).
|
|
77
78
|
Report vulnerabilities through the [security policy](SECURITY.md).
|
|
78
79
|
|
|
79
80
|
## License
|
|
@@ -48,20 +48,21 @@ module OpenUI
|
|
|
48
48
|
status: :unprocessable_content
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Request errors return their own message. Any other gem error is a
|
|
52
|
+
# server-side fault, possibly after the handler ran: log it and return a
|
|
53
|
+
# generic message so configuration details never reach the browser.
|
|
51
54
|
def render_domain_error(error)
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
code = error_code(error)
|
|
56
|
+
if code == "error"
|
|
57
|
+
logger&.error("[openui-rails] action failed: #{error.class}: #{error.message}")
|
|
58
|
+
render json: { error: { code: code, message: "action failed" } }, status: :internal_server_error
|
|
59
|
+
else
|
|
60
|
+
render json: { error: { code: code, message: error.message } }, status: error_status(error)
|
|
61
|
+
end
|
|
54
62
|
end
|
|
55
63
|
|
|
56
64
|
def error_code(error)
|
|
57
|
-
|
|
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
|
+
Instrumentation.action_error_code(error)
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
def error_status(error)
|
|
@@ -69,7 +70,8 @@ module OpenUI
|
|
|
69
70
|
when UnknownActionError then :not_found
|
|
70
71
|
when UnauthorizedActionError then :forbidden
|
|
71
72
|
when StaleSurfaceError, ReplayError then :conflict
|
|
72
|
-
|
|
73
|
+
when InvalidActionError then :unprocessable_content
|
|
74
|
+
else :internal_server_error
|
|
73
75
|
end
|
|
74
76
|
end
|
|
75
77
|
end
|
|
@@ -12,16 +12,18 @@ function loadRuntime(url, integrity) {
|
|
|
12
12
|
script.async = true
|
|
13
13
|
script.crossOrigin = "anonymous"
|
|
14
14
|
if (integrity) script.integrity = integrity
|
|
15
|
+
const failed = (message) => {
|
|
16
|
+
script.remove()
|
|
17
|
+
reject(Object.assign(new Error(message), { code: "runtime_load_failed" }))
|
|
18
|
+
}
|
|
15
19
|
script.addEventListener("load", () => {
|
|
16
20
|
if (!window.__OpenUI) {
|
|
17
|
-
|
|
21
|
+
failed("OpenUI runtime did not expose window.__OpenUI")
|
|
18
22
|
return
|
|
19
23
|
}
|
|
20
24
|
resolve(window.__OpenUI)
|
|
21
25
|
})
|
|
22
|
-
script.addEventListener("error", () =>
|
|
23
|
-
reject(new Error(`Failed to load the OpenUI runtime from ${url}`))
|
|
24
|
-
)
|
|
26
|
+
script.addEventListener("error", () => failed("Failed to load the OpenUI runtime"))
|
|
25
27
|
document.head.appendChild(script)
|
|
26
28
|
})
|
|
27
29
|
promise.catch(() => runtimePromises.delete(url))
|
|
@@ -71,6 +73,12 @@ export default class extends Controller {
|
|
|
71
73
|
|
|
72
74
|
this.syncBusy()
|
|
73
75
|
this.renderRuntime(connection)
|
|
76
|
+
// A surface restored from the Turbo cache or reattached while paused for a
|
|
77
|
+
// snapshot would otherwise wait forever: the host abandoned the earlier
|
|
78
|
+
// request on disconnect. Announce it again once sibling controllers connect.
|
|
79
|
+
if (this.state.needsSnapshot) {
|
|
80
|
+
queueMicrotask(() => { if (this.isCurrent(connection)) this.announceSnapshotRequired() })
|
|
81
|
+
}
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
disconnect() {
|
|
@@ -80,6 +88,8 @@ export default class extends Controller {
|
|
|
80
88
|
this.abortController?.abort()
|
|
81
89
|
this.root?.unmount()
|
|
82
90
|
this.root = null
|
|
91
|
+
this.runtime = null
|
|
92
|
+
this.tools = null
|
|
83
93
|
this.mountPoint?.remove()
|
|
84
94
|
this.mountPoint = null
|
|
85
95
|
}
|
|
@@ -88,11 +98,11 @@ export default class extends Controller {
|
|
|
88
98
|
try {
|
|
89
99
|
const runtime = await loadRuntime(this.runtimeUrl, this.runtimeIntegrity)
|
|
90
100
|
await this.verifyLibrary(runtime.openuiChatLibrary)
|
|
91
|
-
if (
|
|
101
|
+
if (!this.isCurrent(connection) || !this.mountPoint) return
|
|
92
102
|
this.runtime = runtime
|
|
93
|
-
this.render()
|
|
103
|
+
if (this.render()) this.dispatch("ready", { surfaceId: this.surfaceId })
|
|
94
104
|
} catch (error) {
|
|
95
|
-
if (
|
|
105
|
+
if (!this.isCurrent(connection)) return
|
|
96
106
|
this.state.streaming = false
|
|
97
107
|
this.syncBusy()
|
|
98
108
|
this.handleError(error)
|
|
@@ -104,6 +114,7 @@ export default class extends Controller {
|
|
|
104
114
|
if (!this.runtime || !this.mountPoint || !this.connection) return
|
|
105
115
|
if (!this.withinByteLimit(this.state.source)) return
|
|
106
116
|
const { React, createRoot, Renderer, openuiChatLibrary } = this.runtime
|
|
117
|
+
const connection = this.connection
|
|
107
118
|
if (!this.root) this.root = createRoot(this.mountPoint)
|
|
108
119
|
this.root.render(
|
|
109
120
|
React.createElement(Renderer, {
|
|
@@ -115,12 +126,13 @@ export default class extends Controller {
|
|
|
115
126
|
initialState: this.initialState,
|
|
116
127
|
// The official runtime emits { type, params }. It does not await this
|
|
117
128
|
// void callback; dispatchAction already emits a structured error.
|
|
118
|
-
onAction: (action) => { this.dispatchAction(action).catch(() => {}) },
|
|
119
|
-
onStateUpdate: (formState) => this.persistState(formState),
|
|
129
|
+
onAction: (action) => { if (this.isCurrent(connection)) this.dispatchAction(action).catch(() => {}) },
|
|
130
|
+
onStateUpdate: (formState) => { if (this.isCurrent(connection)) this.persistState(formState) },
|
|
120
131
|
toolProvider: this.toolProvider(),
|
|
121
|
-
onError: (error) => this.handleError(error),
|
|
132
|
+
onError: (error) => { if (this.isCurrent(connection)) this.handleError(error) },
|
|
122
133
|
})
|
|
123
134
|
)
|
|
135
|
+
return true
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
async verifyLibrary(library) {
|
|
@@ -153,7 +165,10 @@ export default class extends Controller {
|
|
|
153
165
|
(envelope.type === "openui.snapshot" && typeof envelope.response !== "string")) {
|
|
154
166
|
return this.handleError(Object.assign(new Error("Malformed stream envelope"), { code: "invalid_envelope" }))
|
|
155
167
|
}
|
|
156
|
-
|
|
168
|
+
// Without a stored library version, the verified runtime fingerprint is
|
|
169
|
+
// still the only library this mount can render.
|
|
170
|
+
const library = this.libraryVersion || this.element.dataset.openuiLibraryFingerprint
|
|
171
|
+
if (envelope.library_version && library && envelope.library_version !== library) {
|
|
157
172
|
return this.handleError(Object.assign(new Error("OpenUI library version mismatch"), { code: "library_version_mismatch" }))
|
|
158
173
|
}
|
|
159
174
|
if (envelope.type !== "openui.snapshot") {
|
|
@@ -203,11 +218,16 @@ export default class extends Controller {
|
|
|
203
218
|
this.state.streaming = envelope.streaming === true
|
|
204
219
|
this.state.completed = !this.state.streaming
|
|
205
220
|
this.render()
|
|
221
|
+
this.dispatch("snapshot-applied", { surfaceId: this.surfaceId, sequence: envelope.sequence })
|
|
206
222
|
}
|
|
207
223
|
|
|
208
224
|
requestSnapshot() {
|
|
209
225
|
if (this.state.needsSnapshot) return
|
|
210
226
|
this.state.needsSnapshot = true
|
|
227
|
+
this.announceSnapshotRequired()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
announceSnapshotRequired() {
|
|
211
231
|
this.dispatch("snapshot-required", { surfaceId: this.surfaceId, sequence: this.state.nextSequence })
|
|
212
232
|
}
|
|
213
233
|
|
|
@@ -224,6 +244,8 @@ export default class extends Controller {
|
|
|
224
244
|
// --- Actions -------------------------------------------------------------
|
|
225
245
|
|
|
226
246
|
async dispatchAction(payload = {}) {
|
|
247
|
+
const connection = this.connection
|
|
248
|
+
if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
|
|
227
249
|
const body = {
|
|
228
250
|
action: payload.action ?? payload.name ?? payload.type,
|
|
229
251
|
args: this.actionArguments(payload),
|
|
@@ -241,6 +263,7 @@ export default class extends Controller {
|
|
|
241
263
|
headers: this.headers(),
|
|
242
264
|
body: JSON.stringify(body),
|
|
243
265
|
})
|
|
266
|
+
if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
|
|
244
267
|
if (response.redirected) {
|
|
245
268
|
throw Object.assign(new Error("Action request was redirected; authentication may have expired"), { code: "unauthorized" })
|
|
246
269
|
}
|
|
@@ -253,8 +276,9 @@ export default class extends Controller {
|
|
|
253
276
|
} catch {
|
|
254
277
|
throw Object.assign(new Error("Action endpoint returned malformed JSON"), { code: "invalid_response" })
|
|
255
278
|
}
|
|
279
|
+
if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
|
|
256
280
|
if (!response.ok) {
|
|
257
|
-
throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code })
|
|
281
|
+
throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code || "action_failed" })
|
|
258
282
|
}
|
|
259
283
|
const result = json?.result
|
|
260
284
|
if (!result || result.action !== body.action || result.surface_id !== body.surface_id || result.version !== body.version) {
|
|
@@ -263,7 +287,7 @@ export default class extends Controller {
|
|
|
263
287
|
this.dispatch("action", { surfaceId: this.surfaceId, result })
|
|
264
288
|
return result
|
|
265
289
|
} catch (error) {
|
|
266
|
-
if (error.name
|
|
290
|
+
if (this.isCurrent(connection)) this.handleError(error, error.name === "AbortError" ? "request_cancelled" : "network_error")
|
|
267
291
|
throw error
|
|
268
292
|
}
|
|
269
293
|
}
|
|
@@ -284,6 +308,7 @@ export default class extends Controller {
|
|
|
284
308
|
// Every browser `Query()`/`Mutation()` tool call is routed to the same
|
|
285
309
|
// server endpoint; the host registry decides what is permitted.
|
|
286
310
|
toolProvider() {
|
|
311
|
+
const connection = this.connection
|
|
287
312
|
const reserved = new Set([ "callTool", "then", "__proto__", "constructor", "prototype", "toJSON" ])
|
|
288
313
|
return this.tools ||= new Proxy(
|
|
289
314
|
{},
|
|
@@ -291,7 +316,10 @@ export default class extends Controller {
|
|
|
291
316
|
has: (_target, name) => typeof name === "string" && !reserved.has(name),
|
|
292
317
|
get: (_target, name) => {
|
|
293
318
|
if (typeof name !== "string" || reserved.has(name)) return undefined
|
|
294
|
-
return (args) =>
|
|
319
|
+
return (args) => {
|
|
320
|
+
if (!this.isCurrent(connection)) return Promise.reject(new DOMException("Surface disconnected", "AbortError"))
|
|
321
|
+
return this.dispatchAction({ action: name, args: args ?? {} }).then((result) => result.value)
|
|
322
|
+
}
|
|
295
323
|
},
|
|
296
324
|
}
|
|
297
325
|
)
|
|
@@ -310,17 +338,19 @@ export default class extends Controller {
|
|
|
310
338
|
this.element.setAttribute("aria-busy", this.state.streaming ? "true" : "false")
|
|
311
339
|
}
|
|
312
340
|
|
|
313
|
-
handleError(error) {
|
|
341
|
+
handleError(error, fallbackCode = "renderer_error") {
|
|
314
342
|
// Renderer#onError receives an array, including [] after successful parses.
|
|
315
343
|
if (Array.isArray(error)) {
|
|
316
344
|
if (error.length === 0) return
|
|
317
345
|
error = Object.assign(new Error(error.map((entry) => entry.message || String(entry)).join("; ")),
|
|
318
346
|
{ code: "renderer_error" })
|
|
319
347
|
}
|
|
320
|
-
|
|
321
|
-
|
|
348
|
+
this.dispatch("error", { surfaceId: this.surfaceId, message: error?.message,
|
|
349
|
+
code: typeof error?.code === "string" ? error.code : fallbackCode })
|
|
322
350
|
}
|
|
323
351
|
|
|
352
|
+
isCurrent(connection) { return !!connection && connection === this.connection && this.element.isConnected }
|
|
353
|
+
|
|
324
354
|
headers() {
|
|
325
355
|
const token = document.querySelector("meta[name='csrf-token']")?.content
|
|
326
356
|
const headers = { "Content-Type": "application/json", Accept: "application/json" }
|
|
@@ -6,12 +6,17 @@ OpenUI runtime, SQLite, importmap, and HTTP Turbo Streams. It opts into the
|
|
|
6
6
|
Active Record idempotency store and retains a unique business receipt for closing
|
|
7
7
|
a ticket. Shared claims do not replace that database constraint.
|
|
8
8
|
|
|
9
|
+
The plain HTML fallback provides ticket details, an explicit reload, and a fresh
|
|
10
|
+
sign-in. Snapshot recovery is scoped and single-flight. Unknown action outcomes
|
|
11
|
+
are never retried automatically. Browser checks cover failure feedback, keyboard
|
|
12
|
+
recovery, and Turbo restoration as well as the successful workflow.
|
|
13
|
+
|
|
9
14
|
From the repository:
|
|
10
15
|
|
|
11
16
|
```sh
|
|
12
17
|
mkdir -p tmp/package
|
|
13
|
-
bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.
|
|
14
|
-
bin/check-reference --gem tmp/package/openui-rails-0.
|
|
18
|
+
bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.9.0.gem
|
|
19
|
+
bin/check-reference --gem tmp/package/openui-rails-0.9.0.gem --runtime tmp/real-runtime
|
|
15
20
|
```
|
|
16
21
|
|
|
17
22
|
Fetch the runtime first. See the [complete guide](https://openui-rails.wangyaodi.com/reference-workflow/)
|
|
@@ -5,37 +5,68 @@ import EnvelopeController from "openui/rails/openui_envelope_controller"
|
|
|
5
5
|
|
|
6
6
|
class SupportDeskController extends Controller {
|
|
7
7
|
connect() {
|
|
8
|
+
this.connection = Symbol("support-desk")
|
|
8
9
|
this.abort = new AbortController()
|
|
9
10
|
this.saves = Promise.resolve()
|
|
10
11
|
this.lastState = null
|
|
11
|
-
|
|
12
|
+
this.runtimeReady = false
|
|
13
|
+
this.recovering = false
|
|
14
|
+
this.failureCode = this.element.dataset.supportDeskFailure || null
|
|
15
|
+
this.beforeCache = () => this.disconnect()
|
|
16
|
+
document.addEventListener("turbo:before-cache", this.beforeCache)
|
|
17
|
+
if (this.surface.dataset.openuiStreaming === "true" && !this.failureCode) {
|
|
18
|
+
const connection = this.connection
|
|
19
|
+
this.stream(connection).catch(error => { if (this.current(connection)) this.failure(error) })
|
|
20
|
+
}
|
|
12
21
|
}
|
|
13
22
|
|
|
14
|
-
disconnect() {
|
|
23
|
+
disconnect() {
|
|
24
|
+
this.connection = null
|
|
25
|
+
this.abort?.abort()
|
|
26
|
+
document.removeEventListener("turbo:before-cache", this.beforeCache)
|
|
27
|
+
}
|
|
28
|
+
current(connection) { return !!connection && connection === this.connection && this.element.isConnected }
|
|
15
29
|
get surface() { return this.element.querySelector("[data-controller='openui']") }
|
|
16
30
|
get status() { return this.element.querySelector("#desk-status") }
|
|
31
|
+
get fallback() { return this.element.querySelector("#desk-fallback") }
|
|
32
|
+
get recoverButton() { return this.element.querySelector("#desk-recover") }
|
|
17
33
|
|
|
18
34
|
headers() {
|
|
19
35
|
return { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content }
|
|
20
36
|
}
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
if (response.redirected ||
|
|
38
|
+
checked(response) {
|
|
39
|
+
if (response.redirected || [401, 403].includes(response.status)) {
|
|
40
|
+
throw Object.assign(new Error("Session or permission rejected"), { code: "unauthorized" })
|
|
41
|
+
}
|
|
42
|
+
if (!response.ok) throw Object.assign(new Error("Request rejected"), {
|
|
43
|
+
code: response.status === 409 ? "stale_surface" : "request_failed"
|
|
44
|
+
})
|
|
24
45
|
return response
|
|
25
46
|
}
|
|
26
47
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
ready() {
|
|
49
|
+
this.runtimeReady = true
|
|
50
|
+
if (!this.failureCode) {
|
|
51
|
+
this.fallback.hidden = true
|
|
52
|
+
this.surface.inert = false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Persisted canonical output is independent of this disposable connection.
|
|
57
|
+
async stream(connection) {
|
|
58
|
+
const response = await fetch(this.element.dataset.supportDeskStreamUrl, {
|
|
31
59
|
method: "POST", headers: this.headers(), signal: this.abort.signal, credentials: "same-origin"
|
|
32
|
-
})
|
|
60
|
+
})
|
|
61
|
+
if (!this.current(connection)) return
|
|
62
|
+
this.checked(response)
|
|
33
63
|
const reader = response.body.getReader()
|
|
34
64
|
const decoder = new TextDecoder()
|
|
35
65
|
let pending = ""
|
|
36
66
|
try {
|
|
37
67
|
while (true) {
|
|
38
68
|
const { value, done } = await reader.read()
|
|
69
|
+
if (!this.current(connection)) return
|
|
39
70
|
pending += decoder.decode(value, { stream: !done })
|
|
40
71
|
let end
|
|
41
72
|
while ((end = pending.indexOf("\n")) >= 0) {
|
|
@@ -44,26 +75,54 @@ class SupportDeskController extends Controller {
|
|
|
44
75
|
}
|
|
45
76
|
if (done) break
|
|
46
77
|
}
|
|
47
|
-
if (pending.trim()) throw new Error("Incomplete stream
|
|
48
|
-
this.status.textContent = "Response loaded"
|
|
78
|
+
if (pending.trim()) throw new Error("Incomplete stream")
|
|
79
|
+
if (!this.failureCode) this.status.textContent = "Response loaded"
|
|
49
80
|
} finally {
|
|
50
81
|
await reader.cancel().catch(() => {})
|
|
51
82
|
}
|
|
52
83
|
}
|
|
53
84
|
|
|
54
85
|
async recover() {
|
|
55
|
-
if (this.recovering) return
|
|
86
|
+
if (!this.current(this.connection) || this.recovering || ["unauthorized", "csrf_failed"].includes(this.failureCode)) return
|
|
87
|
+
const connection = this.connection
|
|
56
88
|
this.recovering = true
|
|
89
|
+
this.failure({ code: "sequence_gap" })
|
|
90
|
+
this.recoverButton.disabled = true
|
|
91
|
+
this.recoverButton.setAttribute("aria-busy", "true")
|
|
92
|
+
this.status.textContent = "Restoring saved response"
|
|
57
93
|
try {
|
|
58
|
-
const response = await
|
|
94
|
+
const response = await fetch(this.element.dataset.supportDeskSnapshotUrl, {
|
|
59
95
|
headers: { Accept: "application/json" }, signal: this.abort.signal, credentials: "same-origin"
|
|
60
|
-
})
|
|
61
|
-
|
|
96
|
+
})
|
|
97
|
+
if (!this.current(connection)) return
|
|
98
|
+
const envelope = await this.checked(response).json()
|
|
99
|
+
if (!this.current(connection)) return
|
|
100
|
+
this.snapshotApplied = false
|
|
62
101
|
this.surface.dispatchEvent(new CustomEvent("openui:envelope", { bubbles: true, detail: envelope }))
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
finally {
|
|
102
|
+
if (!this.snapshotApplied) throw Object.assign(new Error("Snapshot not accepted"), { code: "snapshot_unavailable" })
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (this.current(connection)) this.failure(error)
|
|
105
|
+
} finally {
|
|
106
|
+
if (this.current(connection)) {
|
|
107
|
+
this.recovering = false
|
|
108
|
+
this.recoverButton.disabled = this.failureCode !== "sequence_gap" && this.failureCode !== "request_failed"
|
|
109
|
+
this.recoverButton.setAttribute("aria-busy", "false")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
restored() {
|
|
115
|
+
this.snapshotApplied = true
|
|
116
|
+
// A transport snapshot cannot restore identity or permission. Keep that
|
|
117
|
+
// failure visible until the host performs a fresh sign-in or reload.
|
|
118
|
+
if (this.failureCode && !["sequence_gap", "request_failed"].includes(this.failureCode)) return
|
|
119
|
+
this.failureCode = null
|
|
120
|
+
delete this.element.dataset.supportDeskFailure
|
|
121
|
+
this.element.dataset.recovered = "true"
|
|
122
|
+
this.element.querySelector("#desk-error").textContent = ""
|
|
123
|
+
this.status.textContent = "Recovered from a missing update"
|
|
124
|
+
this.surface.inert = false
|
|
125
|
+
if (this.runtimeReady) this.fallback.hidden = true
|
|
67
126
|
}
|
|
68
127
|
|
|
69
128
|
saveState(event) {
|
|
@@ -74,32 +133,58 @@ class SupportDeskController extends Controller {
|
|
|
74
133
|
if (this.lastState === serialized) return
|
|
75
134
|
this.lastState = serialized
|
|
76
135
|
this.status.textContent = "Saving draft"
|
|
136
|
+
const connection = this.connection, signal = this.abort.signal
|
|
77
137
|
const version = Number(this.surface.dataset.openuiVersion)
|
|
78
138
|
this.saves = this.saves.then(async () => {
|
|
79
|
-
|
|
80
|
-
|
|
139
|
+
if (!this.current(connection)) return
|
|
140
|
+
const response = await fetch(this.element.dataset.supportDeskStateUrl, {
|
|
141
|
+
method: "PATCH", headers: this.headers(), credentials: "same-origin", signal,
|
|
81
142
|
body: JSON.stringify({ state, version })
|
|
82
|
-
})
|
|
83
|
-
if (this.
|
|
84
|
-
|
|
143
|
+
})
|
|
144
|
+
if (!this.current(connection)) return
|
|
145
|
+
this.checked(response)
|
|
146
|
+
if (this.lastState === serialized && !this.failureCode) this.status.textContent = "Draft saved"
|
|
147
|
+
}).catch(error => { if (this.current(connection)) this.failure(error) })
|
|
85
148
|
}
|
|
86
149
|
|
|
87
150
|
async action(event) {
|
|
88
151
|
if (!["continue_conversation", "close_ticket"].includes(event.detail.result.action)) return
|
|
152
|
+
const connection = this.connection
|
|
89
153
|
await this.saves
|
|
90
|
-
window.location.reload()
|
|
154
|
+
if (this.current(connection)) window.location.reload()
|
|
91
155
|
}
|
|
92
156
|
|
|
93
157
|
error(event) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this.failure(new Error(event.detail.message || "Unable to render this response."))
|
|
99
|
-
}
|
|
158
|
+
// The runtime can report a secondary parse failure after an action error.
|
|
159
|
+
// Keep the actionable server outcome instead of replacing it with that echo.
|
|
160
|
+
if (this.failureCode && event.detail.code === "renderer_error") return
|
|
161
|
+
this.failure(event.detail)
|
|
100
162
|
}
|
|
163
|
+
|
|
101
164
|
failure(error) {
|
|
102
|
-
if (error.name
|
|
165
|
+
if (error.name === "AbortError") return
|
|
166
|
+
this.failureCode = error.code || "request_failed"
|
|
167
|
+
this.element.dataset.supportDeskFailure = this.failureCode
|
|
168
|
+
const messages = {
|
|
169
|
+
runtime_load_failed: "Interactive view could not load. Review the ticket details or reload.",
|
|
170
|
+
library_version_mismatch: "The response uses a different library. Reload after correcting setup.",
|
|
171
|
+
provider_rejected: "Generation stopped. Reload the saved conversation before trying again.",
|
|
172
|
+
stream_failed: "Generation stopped. Reload the saved conversation before trying again.",
|
|
173
|
+
stream_cancelled: "Generation stopped. Reload the saved conversation before trying again.",
|
|
174
|
+
stream_too_large: "The response is too large. Reload the saved conversation.",
|
|
175
|
+
stale_surface: "Conversation changed. Reload before submitting another action.",
|
|
176
|
+
unauthorized: "Action not permitted. Sign in again.",
|
|
177
|
+
csrf_failed: "Action not permitted. Sign in again.",
|
|
178
|
+
sequence_gap: "An update is missing. Restore the saved response or reload.",
|
|
179
|
+
snapshot_unavailable: "Saved response cannot be applied. Reload the conversation.",
|
|
180
|
+
renderer_error: "Interactive response could not render. Reload the conversation."
|
|
181
|
+
}
|
|
182
|
+
this.element.querySelector("#desk-error").textContent = messages[this.failureCode] || "Request failed; its outcome may be unknown. Reload canonical state before trying again."
|
|
183
|
+
this.fallback.hidden = false
|
|
184
|
+
this.surface.inert = true
|
|
185
|
+
this.recoverButton.disabled = !["sequence_gap", "request_failed"].includes(this.failureCode)
|
|
186
|
+
this.element.querySelector("#desk-sign-in").hidden = !["unauthorized", "csrf_failed"].includes(this.failureCode)
|
|
187
|
+
this.status.textContent = "Recovery required"
|
|
103
188
|
}
|
|
104
189
|
}
|
|
105
190
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<header>
|
|
2
2
|
<h1>Support Desk</h1>
|
|
3
3
|
<span><%= current_actor.name %> · <%= current_actor.tenant_id %> tenant</span>
|
|
4
|
+
<%= link_to "Conversations", root_path %>
|
|
4
5
|
<%= button_to "Sign out", session_path, method: :delete %>
|
|
5
6
|
</header>
|
|
6
7
|
<p class="notice">Recorded response, real OpenUI runtime. Your Rails host owns the ticket and conversation.</p>
|
|
@@ -8,9 +9,18 @@
|
|
|
8
9
|
data-support-desk-stream-url="<%= stream_conversation_path(@conversation) %>"
|
|
9
10
|
data-support-desk-snapshot-url="<%= snapshot_conversation_path(@conversation) %>"
|
|
10
11
|
data-support-desk-state-url="<%= state_conversation_path(@conversation) %>"
|
|
11
|
-
data-action="openui:state->support-desk#saveState openui:snapshot-required->support-desk#recover openui:action->support-desk#action openui:error->support-desk#error">
|
|
12
|
+
data-action="openui:ready->support-desk#ready openui:snapshot-applied->support-desk#restored openui:state->support-desk#saveState openui:snapshot-required->support-desk#recover openui:action->support-desk#action openui:error->support-desk#error">
|
|
12
13
|
<%= openui_surface(surface: @conversation.surface, label: "Support ticket") %>
|
|
13
14
|
<div id="desk-envelopes" hidden></div>
|
|
14
15
|
<p role="status" id="desk-status">Ready</p>
|
|
15
16
|
<p role="alert" id="desk-error"></p>
|
|
17
|
+
<aside id="desk-fallback" class="notice" aria-labelledby="desk-fallback-title">
|
|
18
|
+
<h2 id="desk-fallback-title">Ticket details and recovery</h2>
|
|
19
|
+
<p><%= @conversation.ticket.title %> · <%= @conversation.ticket.status %></p>
|
|
20
|
+
<p>If the interactive view is unavailable, review these saved details. Reloading does not resubmit your last action.</p>
|
|
21
|
+
<%= link_to "Reload conversation", conversation_path(@conversation), data: { turbo: false } %>
|
|
22
|
+
<%= link_to "Sign in again", root_path, id: "desk-sign-in", data: { turbo: false }, hidden: true %>
|
|
23
|
+
<button id="desk-recover" type="button" disabled aria-busy="false" aria-describedby="desk-error"
|
|
24
|
+
data-action="support-desk#recover">Restore saved response</button>
|
|
25
|
+
</aside>
|
|
16
26
|
</section>
|
|
@@ -78,8 +78,16 @@ module OpenUI
|
|
|
78
78
|
#
|
|
79
79
|
# @return [Result]
|
|
80
80
|
#
|
|
81
|
-
#
|
|
81
|
+
# Emits one content-free action.openui_rails outcome notification.
|
|
82
|
+
# Failed gates run before the handler. Handler and storage failures can
|
|
83
|
+
# occur after a business effect; hosts must reconcile that outcome.
|
|
84
|
+
#
|
|
85
|
+
# @raise [OpenUI::Rails::Error] on a failed gate or domain operation
|
|
82
86
|
def call(payload, actor: nil, surface: nil, tenant: nil, context: {})
|
|
87
|
+
started = Instrumentation.now
|
|
88
|
+
outcome = "rejected"
|
|
89
|
+
replayed = false
|
|
90
|
+
code = nil
|
|
83
91
|
submission = Submission.parse(payload)
|
|
84
92
|
definition = @registry.fetch(submission.action)
|
|
85
93
|
|
|
@@ -102,11 +110,22 @@ module OpenUI
|
|
|
102
110
|
authorize!(definition, invocation)
|
|
103
111
|
|
|
104
112
|
replayed, stored, key, token = claim_idempotency(submission, invocation)
|
|
105
|
-
|
|
113
|
+
if replayed
|
|
114
|
+
outcome = "replayed"
|
|
115
|
+
return result(submission, stored, replayed: true)
|
|
116
|
+
end
|
|
106
117
|
|
|
118
|
+
outcome = "failed"
|
|
107
119
|
value = definition.handler.call(invocation)
|
|
108
120
|
@idempotency_store.record(key, value, claim_token: token)
|
|
121
|
+
outcome = "succeeded"
|
|
109
122
|
result(submission, value, replayed: false)
|
|
123
|
+
rescue StandardError => error
|
|
124
|
+
code = Instrumentation.action_error_code(error)
|
|
125
|
+
outcome = "failed" unless error.is_a?(Error)
|
|
126
|
+
raise
|
|
127
|
+
ensure
|
|
128
|
+
Instrumentation.publish("action", started: started, outcome: outcome, code: code, replayed: replayed == true)
|
|
110
129
|
end
|
|
111
130
|
|
|
112
131
|
private
|
|
@@ -17,6 +17,10 @@ module OpenUI
|
|
|
17
17
|
module RubyLLM
|
|
18
18
|
STRATEGIES = %i[complete ask].freeze
|
|
19
19
|
|
|
20
|
+
# Internal signal that aborts the provider stream after a terminal frame.
|
|
21
|
+
Halt = Class.new(StandardError)
|
|
22
|
+
private_constant :Halt
|
|
23
|
+
|
|
20
24
|
class << self
|
|
21
25
|
# Stream a RubyLLM completion, yielding ordered {Stream::Envelope}s.
|
|
22
26
|
#
|
|
@@ -47,6 +51,7 @@ module OpenUI
|
|
|
47
51
|
|
|
48
52
|
begin
|
|
49
53
|
consumer_failed = false
|
|
54
|
+
halted = false
|
|
50
55
|
deliver = lambda do |envelope|
|
|
51
56
|
begin
|
|
52
57
|
block.call(envelope)
|
|
@@ -60,10 +65,17 @@ module OpenUI
|
|
|
60
65
|
next if text.nil?
|
|
61
66
|
|
|
62
67
|
writer.push(text).each(&deliver)
|
|
68
|
+
# A terminal frame (the byte limit) has already been delivered.
|
|
69
|
+
# Raise out of the provider callback so it stops generating.
|
|
70
|
+
if writer.terminated?
|
|
71
|
+
halted = true
|
|
72
|
+
raise Halt
|
|
73
|
+
end
|
|
63
74
|
end
|
|
64
75
|
writer.finish.each(&deliver)
|
|
65
76
|
rescue StandardError => e
|
|
66
77
|
raise if consumer_failed
|
|
78
|
+
return writer if halted
|
|
67
79
|
|
|
68
80
|
envelope = writer.error_for(e, code: error_code_for(e))
|
|
69
81
|
block.call(envelope) if envelope
|
|
@@ -29,8 +29,8 @@ module OpenUI
|
|
|
29
29
|
DEFAULT_ACTIONS_ENDPOINT = "/openui-rails/actions"
|
|
30
30
|
|
|
31
31
|
attr_reader :transport, :runtime_assets
|
|
32
|
-
attr_accessor :library_spec_path, :prompt_path,
|
|
33
|
-
:actions_endpoint,
|
|
32
|
+
attr_accessor :library_spec_path, :prompt_path,
|
|
33
|
+
:actions_endpoint,
|
|
34
34
|
:stream_byte_limit, :pending_byte_limit, :idempotency_ttl,
|
|
35
35
|
:authorize_action, :on_error, :action_registry, :surface_resolver, :actor_resolver,
|
|
36
36
|
:runtime_version, :runtime_url, :runtime_integrity, :local_runtime_path,
|
|
@@ -62,6 +62,32 @@ module OpenUI
|
|
|
62
62
|
@local_runtime_path = "/openui"
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# @deprecated Has no effect: the runtime library comes from the loaded
|
|
66
|
+
# bundle and its manifest. Removed in 1.0.
|
|
67
|
+
def library_name
|
|
68
|
+
deprecated_setting(:library_name)
|
|
69
|
+
@library_name
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @deprecated Has no effect. Removed in 1.0.
|
|
73
|
+
def library_name=(value)
|
|
74
|
+
deprecated_setting(:library_name)
|
|
75
|
+
@library_name = value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @deprecated Has no effect: action requests always use the `surface_id`
|
|
79
|
+
# key. Removed in 1.0.
|
|
80
|
+
def surface_id_param
|
|
81
|
+
deprecated_setting(:surface_id_param)
|
|
82
|
+
@surface_id_param
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @deprecated Has no effect. Removed in 1.0.
|
|
86
|
+
def surface_id_param=(value)
|
|
87
|
+
deprecated_setting(:surface_id_param)
|
|
88
|
+
@surface_id_param = value
|
|
89
|
+
end
|
|
90
|
+
|
|
65
91
|
# Set the preferred host stream transport.
|
|
66
92
|
#
|
|
67
93
|
# @param value [Symbol] `:turbo`, `:action_cable`, or `:none`
|
|
@@ -243,6 +269,12 @@ module OpenUI
|
|
|
243
269
|
PromptBuilder.load(self) if prompt_path
|
|
244
270
|
true
|
|
245
271
|
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def deprecated_setting(name)
|
|
276
|
+
OpenUI::Rails.deprecator.warn("config.#{name} has no effect and will be removed; delete it from your initializer")
|
|
277
|
+
end
|
|
246
278
|
end
|
|
247
279
|
|
|
248
280
|
class << self
|
data/lib/openui/rails/engine.rb
CHANGED
|
@@ -27,6 +27,10 @@ module OpenUI
|
|
|
27
27
|
::Rails.autoloaders.main.inflector.inflect("openui" => "OpenUI")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
initializer "openui_rails.deprecator" do |app|
|
|
31
|
+
app.deprecators[:openui_rails] = OpenUI::Rails.deprecator
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
# Expose the surface helper to every host view.
|
|
31
35
|
initializer "openui_rails.view_helper" do
|
|
32
36
|
ActiveSupport.on_load(:action_view) do
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
|
|
5
|
+
module OpenUI
|
|
6
|
+
module Rails
|
|
7
|
+
# Internal publisher for the documented, content-free notification contract.
|
|
8
|
+
module Instrumentation # :nodoc:
|
|
9
|
+
def self.now
|
|
10
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.publish(name, started:, **metadata)
|
|
14
|
+
# Do not wrap application code in instrument's block: Active Support
|
|
15
|
+
# would attach the raw exception and its message if that code raised.
|
|
16
|
+
ActiveSupport::Notifications.instrument("#{name}.openui_rails",
|
|
17
|
+
**metadata, duration_ms: (now - started) * 1000)
|
|
18
|
+
rescue StandardError
|
|
19
|
+
# Observability must not change a business result or mask its exception.
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.action_error_code(error)
|
|
24
|
+
case error
|
|
25
|
+
when UnknownActionError then "unknown_action"
|
|
26
|
+
when UnauthorizedActionError then "unauthorized"
|
|
27
|
+
when StaleSurfaceError then "stale_surface"
|
|
28
|
+
when ReplayError then "replay"
|
|
29
|
+
when InvalidActionError then "invalid_action"
|
|
30
|
+
when Error then "error"
|
|
31
|
+
else "internal_error"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module OpenUI
|
|
4
4
|
module Rails
|
|
5
|
-
#
|
|
6
|
-
# mount element advertises.
|
|
5
|
+
# Internal: resolves the pinned OpenUI browser runtime and the integrity
|
|
6
|
+
# metadata the mount element advertises. Configure assets through
|
|
7
|
+
# {Configuration}; this module is not part of the supported API.
|
|
7
8
|
#
|
|
8
9
|
# The runtime is version-pinned. Self-hosted (the default) points at a file
|
|
9
10
|
# the install generator vendored into the host app; the external strategy
|
|
10
11
|
# requires an explicit, exact-version URL so no floating CDN range is used
|
|
11
12
|
# in production.
|
|
12
|
-
module RuntimeAsset
|
|
13
|
+
module RuntimeAsset # :nodoc:
|
|
13
14
|
PINNED_VERSION = "0.2.1"
|
|
14
15
|
FILE_PREFIX = "openui-browser-bundle"
|
|
15
16
|
|
|
@@ -20,8 +20,12 @@ module OpenUI
|
|
|
20
20
|
@needs_snapshot = false
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
23
|
+
# Apply one envelope for this message.
|
|
24
|
+
#
|
|
25
|
+
# @param envelope [Envelope]
|
|
26
|
+
#
|
|
27
|
+
# @return [Symbol] one of :applied, :duplicate, :gap, :snapshot,
|
|
28
|
+
# :completed, :error, :ignored. After :gap, only a snapshot applies.
|
|
25
29
|
def apply(envelope)
|
|
26
30
|
return :ignored unless envelope.message_id == message_id
|
|
27
31
|
unless envelope.snapshot?
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openui/rails/instrumentation"
|
|
4
|
+
|
|
3
5
|
module OpenUI
|
|
4
6
|
module Rails
|
|
5
7
|
module Stream
|
|
@@ -37,7 +39,8 @@ module OpenUI
|
|
|
37
39
|
|
|
38
40
|
# Yield ordered envelopes for every chunk from a pull-based source.
|
|
39
41
|
# Provider errors become a structured error envelope. Exceptions raised
|
|
40
|
-
# by the consumer block propagate to the caller.
|
|
42
|
+
# by the consumer block propagate to the caller. Pulling stops once the
|
|
43
|
+
# writer terminates, so an oversized response does not drain the source.
|
|
41
44
|
#
|
|
42
45
|
# @param source [#each] yields String chunks
|
|
43
46
|
# @yieldparam envelope [Envelope]
|
|
@@ -58,6 +61,7 @@ module OpenUI
|
|
|
58
61
|
# Transport/consumer failures are not provider failures. Let the
|
|
59
62
|
# host retry or report them without recursively broadcasting errors.
|
|
60
63
|
envelopes.each { |envelope| yield envelope }
|
|
64
|
+
return if writer.terminated?
|
|
61
65
|
end
|
|
62
66
|
writer.finish.each { |envelope| yield envelope }
|
|
63
67
|
end
|
|
@@ -107,8 +111,15 @@ module OpenUI
|
|
|
107
111
|
@total_bytes = 0
|
|
108
112
|
@pending = +""
|
|
109
113
|
@terminated = false
|
|
114
|
+
@started = Instrumentation.now
|
|
110
115
|
end
|
|
111
116
|
|
|
117
|
+
# Whether a completion or error envelope has ended this stream. Stop
|
|
118
|
+
# reading the provider once it is true; later pushes are ignored.
|
|
119
|
+
#
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def terminated? = @terminated
|
|
122
|
+
|
|
112
123
|
# Append a chunk and return a delta once the pending threshold is reached.
|
|
113
124
|
# A chunk that exceeds the total stream limit returns an error envelope.
|
|
114
125
|
#
|
|
@@ -131,7 +142,8 @@ module OpenUI
|
|
|
131
142
|
[ flush_delta ]
|
|
132
143
|
end
|
|
133
144
|
|
|
134
|
-
# Flush pending text and emit the completion envelope once.
|
|
145
|
+
# Flush pending text and emit the completion envelope once. A terminal
|
|
146
|
+
# stream.openui_rails notification describes creation, not delivery.
|
|
135
147
|
#
|
|
136
148
|
# @return [Array<Envelope>] the final delta, if any, and completion
|
|
137
149
|
def finish
|
|
@@ -144,10 +156,12 @@ module OpenUI
|
|
|
144
156
|
)
|
|
145
157
|
@sequence += 1
|
|
146
158
|
@terminated = true
|
|
159
|
+
notify_termination("completed")
|
|
147
160
|
envelopes
|
|
148
161
|
end
|
|
149
162
|
|
|
150
163
|
# Structured, PII-free error envelope for a provider/consumer failure.
|
|
164
|
+
# Calls `on_error` first; an exception raised by that hook is ignored.
|
|
151
165
|
#
|
|
152
166
|
# @param error [Exception]
|
|
153
167
|
#
|
|
@@ -157,7 +171,7 @@ module OpenUI
|
|
|
157
171
|
def error_for(error, code: "stream_failed")
|
|
158
172
|
return nil if @terminated
|
|
159
173
|
|
|
160
|
-
|
|
174
|
+
notify_error(error)
|
|
161
175
|
|
|
162
176
|
message = "stream failed (#{error.class})"
|
|
163
177
|
message = "stream cancelled" if code == "stream_cancelled"
|
|
@@ -184,8 +198,22 @@ module OpenUI
|
|
|
184
198
|
@sequence += 1
|
|
185
199
|
@terminated = true
|
|
186
200
|
@pending.clear
|
|
201
|
+
notify_termination(code == "stream_cancelled" ? "cancelled" : "failed", code)
|
|
187
202
|
envelope
|
|
188
203
|
end
|
|
204
|
+
|
|
205
|
+
# A failing host hook must not suppress the terminal frame or replace
|
|
206
|
+
# the provider error, matching notification subscriber isolation.
|
|
207
|
+
def notify_error(error)
|
|
208
|
+
@on_error&.call(error, message_id: @message_id)
|
|
209
|
+
rescue StandardError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def notify_termination(outcome, code = nil)
|
|
214
|
+
Instrumentation.publish("stream", started: @started, outcome: outcome,
|
|
215
|
+
code: code, sequence: @sequence - 1, bytes: @total_bytes)
|
|
216
|
+
end
|
|
189
217
|
end
|
|
190
218
|
end
|
|
191
219
|
end
|
|
@@ -24,6 +24,7 @@ module OpenUI
|
|
|
24
24
|
attr_reader :transport, :message_id, :library_version
|
|
25
25
|
|
|
26
26
|
# Broadcast every envelope produced by an enumerable source of chunks.
|
|
27
|
+
# Iteration stops after a terminal envelope, such as a byte-limit error.
|
|
27
28
|
#
|
|
28
29
|
# @param source [#each] yields String chunks
|
|
29
30
|
# @param options [Hash] arguments forwarded to the constructor
|
|
@@ -31,7 +32,10 @@ module OpenUI
|
|
|
31
32
|
# @return [Broadcaster]
|
|
32
33
|
def self.broadcast(source, **options)
|
|
33
34
|
broadcaster = new(**options)
|
|
34
|
-
source.each
|
|
35
|
+
source.each do |chunk|
|
|
36
|
+
broadcaster.push(chunk)
|
|
37
|
+
break if broadcaster.writer.terminated?
|
|
38
|
+
end
|
|
35
39
|
broadcaster.finish
|
|
36
40
|
end
|
|
37
41
|
|
data/lib/openui/rails/version.rb
CHANGED
data/lib/openui/rails.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/deprecation"
|
|
3
4
|
require "openui/rails/version"
|
|
4
5
|
|
|
5
6
|
module OpenUI
|
|
@@ -10,10 +11,18 @@ module OpenUI
|
|
|
10
11
|
# into a Rails engine and keeps Rails authoritative for auth, tenancy and
|
|
11
12
|
# persistence.
|
|
12
13
|
module Rails
|
|
14
|
+
# Deprecator for APIs removed in openui-rails 1.0. The engine registers it
|
|
15
|
+
# in `Rails.application.deprecators`, so host deprecation settings apply.
|
|
16
|
+
#
|
|
17
|
+
# @return [ActiveSupport::Deprecation]
|
|
18
|
+
def self.deprecator
|
|
19
|
+
@deprecator ||= ActiveSupport::Deprecation.new("1.0", "openui-rails")
|
|
20
|
+
end
|
|
13
21
|
end
|
|
14
22
|
end
|
|
15
23
|
|
|
16
24
|
require "openui/rails/errors"
|
|
25
|
+
require "openui/rails/instrumentation"
|
|
17
26
|
require "openui/rails/json_value"
|
|
18
27
|
require "openui/rails/library_manifest"
|
|
19
28
|
require "openui/rails/prompt_builder"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openui-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Wang
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -104,6 +104,7 @@ files:
|
|
|
104
104
|
- lib/openui/rails/doctor.rb
|
|
105
105
|
- lib/openui/rails/engine.rb
|
|
106
106
|
- lib/openui/rails/errors.rb
|
|
107
|
+
- lib/openui/rails/instrumentation.rb
|
|
107
108
|
- lib/openui/rails/json_value.rb
|
|
108
109
|
- lib/openui/rails/library_manifest.rb
|
|
109
110
|
- lib/openui/rails/message.rb
|