openui-rails 0.4.0 → 0.5.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 +22 -1
- data/CONTRIBUTING.md +1 -1
- data/DEVELOPMENT.md +1 -1
- data/README.md +4 -4
- data/app/controllers/openui/rails/actions_controller.rb +1 -8
- data/app/javascript/openui/rails/openui_controller.js +33 -16
- 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/instrumentation.rb +36 -0
- data/lib/openui/rails/stream/bridge.rb +12 -1
- data/lib/openui/rails/version.rb +1 -1
- data/lib/openui/rails.rb +1 -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: 9a8e938067e723b80e9efc974e2a2bd19878499bdc459c6d675452fa919584fb
|
|
4
|
+
data.tar.gz: 1a0d9ffaa09afef3c185f21154c56bf78ef8141f15bf4bef8fe1562aa47eb68b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0161351030bb99e07eb7fa9c7fa968c4921ea436e76da253c6b1827ac3a55650e0dfc6903d737cd1ae94a71b9ae978ae20642e9ea9aec5dd5aa0bd550ff5eb2c
|
|
7
|
+
data.tar.gz: 6b583705522c3871e981289572701a5c0c62bb6c9a8cb7679c26a6af9cac185937d4401b960c4a653442f08ef08b8b552468e42deeaf7ba45bcca5d78e491005
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.0] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Content-free `action.openui_rails` and `stream.openui_rails` notifications with
|
|
14
|
+
monotonic duration, fixed outcomes, replay status, and stream watermarks.
|
|
15
|
+
- Browser `openui:ready` and `openui:snapshot-applied` events for host-owned
|
|
16
|
+
fallback and recovery, plus stable runtime and network failure codes.
|
|
17
|
+
- Ordinary HTML fallback and explicit reload, scoped snapshot, and fresh sign-in
|
|
18
|
+
controls in the reference host, with keyboard and Turbo restoration checks.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Late action responses cannot report into a reconnected surface. Failed
|
|
23
|
+
runtime scripts are removed so later mounts can retry loading.
|
|
24
|
+
- Unmounted renderer callbacks cannot send actions, tool requests, state, or
|
|
25
|
+
errors through a new connection. A late snapshot cannot dismiss a host's
|
|
26
|
+
authentication or permission failure.
|
|
27
|
+
- Browser failures use events without automatically logging raw error objects.
|
|
28
|
+
|
|
9
29
|
## [0.4.0] - 2026-10-20
|
|
10
30
|
|
|
11
31
|
### Added
|
|
@@ -133,7 +153,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
133
153
|
- Gem-scoped RubyGems Trusted Publishing through GitHub OIDC, with a protected
|
|
134
154
|
release environment and verified artifact handoff.
|
|
135
155
|
|
|
136
|
-
[Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.
|
|
156
|
+
[Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.5.0...main
|
|
157
|
+
[0.5.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.5.0
|
|
137
158
|
[0.4.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.4.0
|
|
138
159
|
[0.3.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.3.0
|
|
139
160
|
[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.5.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.5.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.5.0"
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
```sh
|
|
@@ -65,15 +65,15 @@ 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.5.0` adds outcome diagnostics and host-owned recovery examples.
|
|
69
|
+
See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-5/) for the upgrade.
|
|
70
70
|
The API may change during `0.x`; see the
|
|
71
71
|
[changelog](CHANGELOG.md) and the proposed [path to 1.0](ROADMAP.md).
|
|
72
72
|
The site's stable channel follows the latest release; [/next/](https://openui-rails.wangyaodi.com/next/)
|
|
73
73
|
follows `main`.
|
|
74
74
|
|
|
75
75
|
For repository work, use [Contributing](CONTRIBUTING.md),
|
|
76
|
-
[Releasing](RELEASING.md), and the [0.
|
|
76
|
+
[Releasing](RELEASING.md), and the [0.4.0 release record](RELEASE_0_4.md).
|
|
77
77
|
Report vulnerabilities through the [security policy](SECURITY.md).
|
|
78
78
|
|
|
79
79
|
## License
|
|
@@ -54,14 +54,7 @@ module OpenUI
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
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
|
|
57
|
+
Instrumentation.action_error_code(error)
|
|
65
58
|
end
|
|
66
59
|
|
|
67
60
|
def error_status(error)
|
|
@@ -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))
|
|
@@ -80,6 +82,8 @@ export default class extends Controller {
|
|
|
80
82
|
this.abortController?.abort()
|
|
81
83
|
this.root?.unmount()
|
|
82
84
|
this.root = null
|
|
85
|
+
this.runtime = null
|
|
86
|
+
this.tools = null
|
|
83
87
|
this.mountPoint?.remove()
|
|
84
88
|
this.mountPoint = null
|
|
85
89
|
}
|
|
@@ -88,11 +92,11 @@ export default class extends Controller {
|
|
|
88
92
|
try {
|
|
89
93
|
const runtime = await loadRuntime(this.runtimeUrl, this.runtimeIntegrity)
|
|
90
94
|
await this.verifyLibrary(runtime.openuiChatLibrary)
|
|
91
|
-
if (
|
|
95
|
+
if (!this.isCurrent(connection) || !this.mountPoint) return
|
|
92
96
|
this.runtime = runtime
|
|
93
|
-
this.render()
|
|
97
|
+
if (this.render()) this.dispatch("ready", { surfaceId: this.surfaceId })
|
|
94
98
|
} catch (error) {
|
|
95
|
-
if (
|
|
99
|
+
if (!this.isCurrent(connection)) return
|
|
96
100
|
this.state.streaming = false
|
|
97
101
|
this.syncBusy()
|
|
98
102
|
this.handleError(error)
|
|
@@ -104,6 +108,7 @@ export default class extends Controller {
|
|
|
104
108
|
if (!this.runtime || !this.mountPoint || !this.connection) return
|
|
105
109
|
if (!this.withinByteLimit(this.state.source)) return
|
|
106
110
|
const { React, createRoot, Renderer, openuiChatLibrary } = this.runtime
|
|
111
|
+
const connection = this.connection
|
|
107
112
|
if (!this.root) this.root = createRoot(this.mountPoint)
|
|
108
113
|
this.root.render(
|
|
109
114
|
React.createElement(Renderer, {
|
|
@@ -115,12 +120,13 @@ export default class extends Controller {
|
|
|
115
120
|
initialState: this.initialState,
|
|
116
121
|
// The official runtime emits { type, params }. It does not await this
|
|
117
122
|
// void callback; dispatchAction already emits a structured error.
|
|
118
|
-
onAction: (action) => { this.dispatchAction(action).catch(() => {}) },
|
|
119
|
-
onStateUpdate: (formState) => this.persistState(formState),
|
|
123
|
+
onAction: (action) => { if (this.isCurrent(connection)) this.dispatchAction(action).catch(() => {}) },
|
|
124
|
+
onStateUpdate: (formState) => { if (this.isCurrent(connection)) this.persistState(formState) },
|
|
120
125
|
toolProvider: this.toolProvider(),
|
|
121
|
-
onError: (error) => this.handleError(error),
|
|
126
|
+
onError: (error) => { if (this.isCurrent(connection)) this.handleError(error) },
|
|
122
127
|
})
|
|
123
128
|
)
|
|
129
|
+
return true
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
async verifyLibrary(library) {
|
|
@@ -203,6 +209,7 @@ export default class extends Controller {
|
|
|
203
209
|
this.state.streaming = envelope.streaming === true
|
|
204
210
|
this.state.completed = !this.state.streaming
|
|
205
211
|
this.render()
|
|
212
|
+
this.dispatch("snapshot-applied", { surfaceId: this.surfaceId, sequence: envelope.sequence })
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
requestSnapshot() {
|
|
@@ -224,6 +231,8 @@ export default class extends Controller {
|
|
|
224
231
|
// --- Actions -------------------------------------------------------------
|
|
225
232
|
|
|
226
233
|
async dispatchAction(payload = {}) {
|
|
234
|
+
const connection = this.connection
|
|
235
|
+
if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
|
|
227
236
|
const body = {
|
|
228
237
|
action: payload.action ?? payload.name ?? payload.type,
|
|
229
238
|
args: this.actionArguments(payload),
|
|
@@ -241,6 +250,7 @@ export default class extends Controller {
|
|
|
241
250
|
headers: this.headers(),
|
|
242
251
|
body: JSON.stringify(body),
|
|
243
252
|
})
|
|
253
|
+
if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
|
|
244
254
|
if (response.redirected) {
|
|
245
255
|
throw Object.assign(new Error("Action request was redirected; authentication may have expired"), { code: "unauthorized" })
|
|
246
256
|
}
|
|
@@ -253,8 +263,9 @@ export default class extends Controller {
|
|
|
253
263
|
} catch {
|
|
254
264
|
throw Object.assign(new Error("Action endpoint returned malformed JSON"), { code: "invalid_response" })
|
|
255
265
|
}
|
|
266
|
+
if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
|
|
256
267
|
if (!response.ok) {
|
|
257
|
-
throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code })
|
|
268
|
+
throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code || "action_failed" })
|
|
258
269
|
}
|
|
259
270
|
const result = json?.result
|
|
260
271
|
if (!result || result.action !== body.action || result.surface_id !== body.surface_id || result.version !== body.version) {
|
|
@@ -263,7 +274,7 @@ export default class extends Controller {
|
|
|
263
274
|
this.dispatch("action", { surfaceId: this.surfaceId, result })
|
|
264
275
|
return result
|
|
265
276
|
} catch (error) {
|
|
266
|
-
if (error.name
|
|
277
|
+
if (this.isCurrent(connection)) this.handleError(error, error.name === "AbortError" ? "request_cancelled" : "network_error")
|
|
267
278
|
throw error
|
|
268
279
|
}
|
|
269
280
|
}
|
|
@@ -284,6 +295,7 @@ export default class extends Controller {
|
|
|
284
295
|
// Every browser `Query()`/`Mutation()` tool call is routed to the same
|
|
285
296
|
// server endpoint; the host registry decides what is permitted.
|
|
286
297
|
toolProvider() {
|
|
298
|
+
const connection = this.connection
|
|
287
299
|
const reserved = new Set([ "callTool", "then", "__proto__", "constructor", "prototype", "toJSON" ])
|
|
288
300
|
return this.tools ||= new Proxy(
|
|
289
301
|
{},
|
|
@@ -291,7 +303,10 @@ export default class extends Controller {
|
|
|
291
303
|
has: (_target, name) => typeof name === "string" && !reserved.has(name),
|
|
292
304
|
get: (_target, name) => {
|
|
293
305
|
if (typeof name !== "string" || reserved.has(name)) return undefined
|
|
294
|
-
return (args) =>
|
|
306
|
+
return (args) => {
|
|
307
|
+
if (!this.isCurrent(connection)) return Promise.reject(new DOMException("Surface disconnected", "AbortError"))
|
|
308
|
+
return this.dispatchAction({ action: name, args: args ?? {} }).then((result) => result.value)
|
|
309
|
+
}
|
|
295
310
|
},
|
|
296
311
|
}
|
|
297
312
|
)
|
|
@@ -310,17 +325,19 @@ export default class extends Controller {
|
|
|
310
325
|
this.element.setAttribute("aria-busy", this.state.streaming ? "true" : "false")
|
|
311
326
|
}
|
|
312
327
|
|
|
313
|
-
handleError(error) {
|
|
328
|
+
handleError(error, fallbackCode = "renderer_error") {
|
|
314
329
|
// Renderer#onError receives an array, including [] after successful parses.
|
|
315
330
|
if (Array.isArray(error)) {
|
|
316
331
|
if (error.length === 0) return
|
|
317
332
|
error = Object.assign(new Error(error.map((entry) => entry.message || String(entry)).join("; ")),
|
|
318
333
|
{ code: "renderer_error" })
|
|
319
334
|
}
|
|
320
|
-
|
|
321
|
-
|
|
335
|
+
this.dispatch("error", { surfaceId: this.surfaceId, message: error?.message,
|
|
336
|
+
code: typeof error?.code === "string" ? error.code : fallbackCode })
|
|
322
337
|
}
|
|
323
338
|
|
|
339
|
+
isCurrent(connection) { return !!connection && connection === this.connection && this.element.isConnected }
|
|
340
|
+
|
|
324
341
|
headers() {
|
|
325
342
|
const token = document.querySelector("meta[name='csrf-token']")?.content
|
|
326
343
|
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.5.0.gem
|
|
19
|
+
bin/check-reference --gem tmp/package/openui-rails-0.5.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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -107,6 +109,7 @@ module OpenUI
|
|
|
107
109
|
@total_bytes = 0
|
|
108
110
|
@pending = +""
|
|
109
111
|
@terminated = false
|
|
112
|
+
@started = Instrumentation.now
|
|
110
113
|
end
|
|
111
114
|
|
|
112
115
|
# Append a chunk and return a delta once the pending threshold is reached.
|
|
@@ -131,7 +134,8 @@ module OpenUI
|
|
|
131
134
|
[ flush_delta ]
|
|
132
135
|
end
|
|
133
136
|
|
|
134
|
-
# Flush pending text and emit the completion envelope once.
|
|
137
|
+
# Flush pending text and emit the completion envelope once. A terminal
|
|
138
|
+
# stream.openui_rails notification describes creation, not delivery.
|
|
135
139
|
#
|
|
136
140
|
# @return [Array<Envelope>] the final delta, if any, and completion
|
|
137
141
|
def finish
|
|
@@ -144,6 +148,7 @@ module OpenUI
|
|
|
144
148
|
)
|
|
145
149
|
@sequence += 1
|
|
146
150
|
@terminated = true
|
|
151
|
+
notify_termination("completed")
|
|
147
152
|
envelopes
|
|
148
153
|
end
|
|
149
154
|
|
|
@@ -184,8 +189,14 @@ module OpenUI
|
|
|
184
189
|
@sequence += 1
|
|
185
190
|
@terminated = true
|
|
186
191
|
@pending.clear
|
|
192
|
+
notify_termination(code == "stream_cancelled" ? "cancelled" : "failed", code)
|
|
187
193
|
envelope
|
|
188
194
|
end
|
|
195
|
+
|
|
196
|
+
def notify_termination(outcome, code = nil)
|
|
197
|
+
Instrumentation.publish("stream", started: @started, outcome: outcome,
|
|
198
|
+
code: code, sequence: @sequence - 1, bytes: @total_bytes)
|
|
199
|
+
end
|
|
189
200
|
end
|
|
190
201
|
end
|
|
191
202
|
end
|
data/lib/openui/rails/version.rb
CHANGED
data/lib/openui/rails.rb
CHANGED
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.5.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
|