openui-rails 0.1.0 → 0.3.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/AUDIT.md +24 -4
- data/CHANGELOG.md +52 -1
- data/CONFIGURATION.md +9 -77
- data/CONTRIBUTING.md +29 -1
- data/DEVELOPMENT.md +89 -0
- data/README.md +50 -439
- data/RELEASING.md +30 -8
- data/THIRD_PARTY_NOTICES.md +10 -0
- data/UPGRADING.md +7 -24
- data/app/javascript/openui/rails/openui_controller.js +14 -1
- data/examples/support_desk/README.md +18 -0
- data/examples/support_desk/app/controllers/support_desk/base_controller.rb +37 -0
- data/examples/support_desk/app/controllers/support_desk/conversations_controller.rb +47 -0
- data/examples/support_desk/app/controllers/support_desk/sessions_controller.rb +19 -0
- data/examples/support_desk/app/javascript/support_desk.js +109 -0
- data/examples/support_desk/app/models/support_desk/conversation.rb +108 -0
- data/examples/support_desk/app/models/support_desk/demo.rb +15 -0
- data/examples/support_desk/app/models/support_desk/receipt.rb +6 -0
- data/examples/support_desk/app/models/support_desk/ticket.rb +6 -0
- data/examples/support_desk/app/models/support_desk/user.rb +6 -0
- data/examples/support_desk/app/views/layouts/support_desk/application.html.erb +18 -0
- data/examples/support_desk/app/views/support_desk/conversations/index.html.erb +6 -0
- data/examples/support_desk/app/views/support_desk/conversations/show.html.erb +16 -0
- data/examples/support_desk/boot.rb +21 -0
- data/examples/support_desk/config/importmap.rb +4 -0
- data/examples/support_desk/config/initializers/openui.rb +32 -0
- data/examples/support_desk/config/routes.rb +12 -0
- data/examples/support_desk/db/migrate/20261019000000_create_support_desk.rb +33 -0
- data/examples/support_desk/install.rb +15 -0
- data/lib/generators/openui/install/install_generator.rb +6 -5
- data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +2 -2
- data/lib/generators/openui/install/templates/config/openui/README.md +19 -4
- data/lib/openui/rails/default_library/library.prompt.txt +198 -0
- data/lib/openui/rails/default_library/library.spec.json +4390 -0
- data/lib/openui/rails/default_library/openui.LICENSE.txt +21 -0
- data/lib/openui/rails/default_library/provenance.json +15 -0
- data/lib/openui/rails/default_library.rb +85 -0
- data/lib/openui/rails/doctor.rb +143 -0
- data/lib/openui/rails/library_manifest.rb +2 -1
- data/lib/openui/rails/surface.rb +22 -4
- data/lib/openui/rails/version.rb +1 -1
- data/lib/openui/rails.rb +2 -0
- data/lib/tasks/openui.rake +19 -0
- metadata +28 -2
|
@@ -226,7 +226,7 @@ export default class extends Controller {
|
|
|
226
226
|
async dispatchAction(payload = {}) {
|
|
227
227
|
const body = {
|
|
228
228
|
action: payload.action ?? payload.name ?? payload.type,
|
|
229
|
-
args:
|
|
229
|
+
args: this.actionArguments(payload),
|
|
230
230
|
surface_id: this.surfaceId,
|
|
231
231
|
version: this.version,
|
|
232
232
|
idempotency_key: payload.idempotency_key ?? this.newIdempotencyKey(),
|
|
@@ -268,6 +268,19 @@ export default class extends Controller {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// The official renderer places conversation metadata beside params. Keep
|
|
272
|
+
// it in the allowlisted action's args so the host can validate and persist it.
|
|
273
|
+
actionArguments(payload) {
|
|
274
|
+
const args = payload.args ?? payload.params ?? {}
|
|
275
|
+
if ((payload.action ?? payload.name ?? payload.type) !== "continue_conversation") return args
|
|
276
|
+
if (typeof args !== "object" || Array.isArray(args)) return args
|
|
277
|
+
const metadata = {}
|
|
278
|
+
if (payload.humanFriendlyMessage !== undefined) metadata.message = payload.humanFriendlyMessage
|
|
279
|
+
if (payload.formState !== undefined) metadata.form_state = payload.formState
|
|
280
|
+
if (payload.formName !== undefined) metadata.form_name = payload.formName
|
|
281
|
+
return { ...args, ...metadata }
|
|
282
|
+
}
|
|
283
|
+
|
|
271
284
|
// Every browser `Query()`/`Mutation()` tool call is routed to the same
|
|
272
285
|
// server endpoint; the host registry decides what is permitted.
|
|
273
286
|
toolProvider() {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Support Desk reference host
|
|
2
|
+
|
|
3
|
+
This opt-in reference belongs to the host application, outside the engine's
|
|
4
|
+
public API. It uses synthetic accounts, recorded generation, the real pinned
|
|
5
|
+
OpenUI runtime, SQLite, importmap, and HTTP Turbo Streams.
|
|
6
|
+
|
|
7
|
+
From the repository:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
mkdir -p tmp/package
|
|
11
|
+
bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.3.0.gem
|
|
12
|
+
bin/check-reference --gem tmp/package/openui-rails-0.3.0.gem --runtime tmp/real-runtime
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Fetch the runtime first. See the [complete guide](https://openui-rails.wangyaodi.com/reference-workflow/)
|
|
16
|
+
for setup, retained-host review, failure checks, and evidence limits. The
|
|
17
|
+
reference is not enabled by requiring the gem. Keep the demo loopback-only;
|
|
18
|
+
replace its synthetic login before adapting it to a real application.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk # :nodoc:
|
|
4
|
+
class BaseController < ::ApplicationController
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
layout "support_desk/application"
|
|
7
|
+
before_action :local_demo_only
|
|
8
|
+
before_action :authenticate
|
|
9
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
10
|
+
render json: { error: { code: "not_found", message: "inaccessible conversation" } }, status: :not_found
|
|
11
|
+
end
|
|
12
|
+
rescue_from OpenUI::Rails::Error do |error|
|
|
13
|
+
status = error.is_a?(OpenUI::Rails::StaleSurfaceError) ? :conflict : :unprocessable_content
|
|
14
|
+
code = error.is_a?(OpenUI::Rails::StaleSurfaceError) ? "stale_surface" : "invalid_state"
|
|
15
|
+
render json: { error: { code: code, message: "request rejected" } }, status: status
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def current_actor
|
|
21
|
+
@current_actor ||= User.find_by(id: session[:support_desk_user_id])
|
|
22
|
+
end
|
|
23
|
+
helper_method :current_actor
|
|
24
|
+
|
|
25
|
+
def local_demo_only
|
|
26
|
+
head :forbidden unless [ "127.0.0.1", "::1" ].include?(request.remote_ip)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def authenticate
|
|
30
|
+
render json: { error: { code: "unauthorized", message: "sign in to the local demo" } }, status: :unauthorized unless current_actor
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def conversation
|
|
34
|
+
@conversation ||= Conversation.find_by!(id: params[:id], user_id: current_actor.id, tenant_id: current_actor.tenant_id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk # :nodoc:
|
|
4
|
+
class ConversationsController < BaseController
|
|
5
|
+
# The state endpoint uses an explicit top-level JSON contract.
|
|
6
|
+
wrap_parameters false
|
|
7
|
+
skip_before_action :authenticate, only: :index
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
conversation
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def stream
|
|
17
|
+
frames = conversation.generate!
|
|
18
|
+
transport = OpenUI::Rails::Stream::Transports::TurboStreams.new("unused", target: "desk-envelopes")
|
|
19
|
+
response.headers["Content-Type"] = "text/vnd.turbo-stream.html; charset=utf-8"
|
|
20
|
+
response.headers["Cache-Control"] = "no-store"
|
|
21
|
+
self.response_body = Enumerator.new do |body|
|
|
22
|
+
frames.each do |frame|
|
|
23
|
+
body << %(<turbo-stream action="append" target="desk-envelopes"><template>#{transport.element(frame)}</template></turbo-stream>\n)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def snapshot
|
|
29
|
+
if conversation.response.empty?
|
|
30
|
+
head :conflict
|
|
31
|
+
else
|
|
32
|
+
render json: conversation.snapshot.to_h
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def state
|
|
37
|
+
record = conversation
|
|
38
|
+
body = request.request_parameters
|
|
39
|
+
unless (body.keys - %w[state version]).empty? && body["version"].is_a?(Integer) && body["version"] >= 0
|
|
40
|
+
raise OpenUI::Rails::InvalidActionError, "invalid state request"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
record.save_form!(body["state"], expected_version: body["version"])
|
|
44
|
+
head :no_content
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk # :nodoc:
|
|
4
|
+
class SessionsController < BaseController
|
|
5
|
+
skip_before_action :authenticate
|
|
6
|
+
|
|
7
|
+
def create
|
|
8
|
+
user = User.find_by!(name: params[:name])
|
|
9
|
+
reset_session
|
|
10
|
+
session[:support_desk_user_id] = user.id
|
|
11
|
+
redirect_to conversation_path(Conversation.find_by!(user_id: user.id))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def destroy
|
|
15
|
+
reset_session
|
|
16
|
+
redirect_to root_path
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Application, Controller } from "@hotwired/stimulus"
|
|
2
|
+
import "@hotwired/turbo-rails"
|
|
3
|
+
import OpenUIController from "openui/rails/openui_controller"
|
|
4
|
+
import EnvelopeController from "openui/rails/openui_envelope_controller"
|
|
5
|
+
|
|
6
|
+
class SupportDeskController extends Controller {
|
|
7
|
+
connect() {
|
|
8
|
+
this.abort = new AbortController()
|
|
9
|
+
this.saves = Promise.resolve()
|
|
10
|
+
this.lastState = null
|
|
11
|
+
if (this.surface.dataset.openuiStreaming === "true") this.stream().catch(error => this.failure(error))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() { this.abort.abort() }
|
|
15
|
+
get surface() { return this.element.querySelector("[data-controller='openui']") }
|
|
16
|
+
get status() { return this.element.querySelector("#desk-status") }
|
|
17
|
+
|
|
18
|
+
headers() {
|
|
19
|
+
return { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async checked(response) {
|
|
23
|
+
if (response.redirected || !response.ok) throw new Error(response.status === 401 || response.status === 403 ? "Session expired. Sign in again." : "Request rejected. Reload the conversation.")
|
|
24
|
+
return response
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Consume newline-delimited Turbo Stream elements as response chunks arrive.
|
|
28
|
+
// Authorization and canonical response persistence happen before delivery.
|
|
29
|
+
async stream() {
|
|
30
|
+
const response = await this.checked(await fetch(this.element.dataset.supportDeskStreamUrl, {
|
|
31
|
+
method: "POST", headers: this.headers(), signal: this.abort.signal, credentials: "same-origin"
|
|
32
|
+
}))
|
|
33
|
+
const reader = response.body.getReader()
|
|
34
|
+
const decoder = new TextDecoder()
|
|
35
|
+
let pending = ""
|
|
36
|
+
try {
|
|
37
|
+
while (true) {
|
|
38
|
+
const { value, done } = await reader.read()
|
|
39
|
+
pending += decoder.decode(value, { stream: !done })
|
|
40
|
+
let end
|
|
41
|
+
while ((end = pending.indexOf("\n")) >= 0) {
|
|
42
|
+
window.Turbo.renderStreamMessage(pending.slice(0, end))
|
|
43
|
+
pending = pending.slice(end + 1)
|
|
44
|
+
}
|
|
45
|
+
if (done) break
|
|
46
|
+
}
|
|
47
|
+
if (pending.trim()) throw new Error("Incomplete stream. Reload the conversation.")
|
|
48
|
+
this.status.textContent = "Response loaded"
|
|
49
|
+
} finally {
|
|
50
|
+
await reader.cancel().catch(() => {})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async recover() {
|
|
55
|
+
if (this.recovering) return
|
|
56
|
+
this.recovering = true
|
|
57
|
+
try {
|
|
58
|
+
const response = await this.checked(await fetch(this.element.dataset.supportDeskSnapshotUrl, {
|
|
59
|
+
headers: { Accept: "application/json" }, signal: this.abort.signal, credentials: "same-origin"
|
|
60
|
+
}))
|
|
61
|
+
const envelope = await response.json()
|
|
62
|
+
this.surface.dispatchEvent(new CustomEvent("openui:envelope", { bubbles: true, detail: envelope }))
|
|
63
|
+
this.element.dataset.recovered = "true"
|
|
64
|
+
this.status.textContent = "Recovered from a missing update"
|
|
65
|
+
} catch (error) { this.failure(error) }
|
|
66
|
+
finally { this.recovering = false }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
saveState(event) {
|
|
70
|
+
const note = event.detail.state?.reply?.note
|
|
71
|
+
if (!note || typeof note.value !== "string") return
|
|
72
|
+
const state = { reply: { note: { value: note.value, componentType: "Input" } } }
|
|
73
|
+
const serialized = JSON.stringify(state)
|
|
74
|
+
if (this.lastState === serialized) return
|
|
75
|
+
this.lastState = serialized
|
|
76
|
+
this.status.textContent = "Saving draft"
|
|
77
|
+
const version = Number(this.surface.dataset.openuiVersion)
|
|
78
|
+
this.saves = this.saves.then(async () => {
|
|
79
|
+
await this.checked(await fetch(this.element.dataset.supportDeskStateUrl, {
|
|
80
|
+
method: "PATCH", headers: this.headers(), credentials: "same-origin", signal: this.abort.signal,
|
|
81
|
+
body: JSON.stringify({ state, version })
|
|
82
|
+
}))
|
|
83
|
+
if (this.lastState === serialized) this.status.textContent = "Draft saved"
|
|
84
|
+
}).catch(error => this.failure(error))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async action(event) {
|
|
88
|
+
if (!["continue_conversation", "close_ticket"].includes(event.detail.result.action)) return
|
|
89
|
+
await this.saves
|
|
90
|
+
window.location.reload()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
error(event) {
|
|
94
|
+
if (["unauthorized", "csrf_failed"].includes(event.detail.code)) {
|
|
95
|
+
this.denied = true
|
|
96
|
+
this.failure(new Error("Action not permitted. Sign in again."))
|
|
97
|
+
} else if (!this.denied) {
|
|
98
|
+
this.failure(new Error(event.detail.message || "Unable to render this response."))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
failure(error) {
|
|
102
|
+
if (error.name !== "AbortError") this.element.querySelector("#desk-error").textContent = error.message
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const application = Application.start()
|
|
107
|
+
application.register("support-desk", SupportDeskController)
|
|
108
|
+
application.register("openui", OpenUIController)
|
|
109
|
+
application.register("openui-envelope", EnvelopeController)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk # :nodoc:
|
|
4
|
+
# Host-owned state and business transactions, outside the gem's public API.
|
|
5
|
+
class Conversation < ActiveRecord::Base
|
|
6
|
+
belongs_to :user
|
|
7
|
+
belongs_to :ticket
|
|
8
|
+
|
|
9
|
+
def message_id = "desk-#{id}-turn-#{version}"
|
|
10
|
+
|
|
11
|
+
def surface
|
|
12
|
+
OpenUI::Rails::Surface.new(
|
|
13
|
+
id: id, version: version, message_id: message_id, response: response,
|
|
14
|
+
library_version: OpenUI::Rails.configuration.library_fingerprint,
|
|
15
|
+
next_sequence: next_sequence, completed: response.present?, streaming: response.empty?, context: form_state
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Persist canonical output before delivery. A disconnect cannot erase it.
|
|
20
|
+
# This deterministic fixture substitutes for a provider, not for the renderer.
|
|
21
|
+
def generate!
|
|
22
|
+
with_lock do
|
|
23
|
+
return [ snapshot ] if response.present?
|
|
24
|
+
|
|
25
|
+
controls = if ticket.reload.status == "closed"
|
|
26
|
+
'TextContent("Ticket is closed")'
|
|
27
|
+
else
|
|
28
|
+
'Buttons([Button("Close ticket", @Run(close))])'
|
|
29
|
+
end
|
|
30
|
+
source = <<~LANG
|
|
31
|
+
ticket = Query("ticket_lookup", {}, {title: "Loading ticket", status: "loading"})
|
|
32
|
+
close = Mutation("close_ticket", {})
|
|
33
|
+
note = FormControl("Private note", Input("note", "Write a note"))
|
|
34
|
+
replyButtons = Buttons([Button("Continue conversation", @ToAssistant("Review this ticket"))])
|
|
35
|
+
reply = Form("reply", replyButtons, [note])
|
|
36
|
+
controls = #{controls}
|
|
37
|
+
root = Card([CardHeader("Support ticket"), TextContent(ticket.title), TextContent(ticket.status), TextContent("Conversation turns: #{messages.length}"), reply, controls])
|
|
38
|
+
LANG
|
|
39
|
+
config = OpenUI::Rails::Configuration.new
|
|
40
|
+
config.pending_byte_limit = 1
|
|
41
|
+
frames = OpenUI::Rails::Stream::Bridge.new(message_id: message_id,
|
|
42
|
+
library_version: OpenUI::Rails.configuration.library_fingerprint, configuration: config).envelopes(source.lines).to_a
|
|
43
|
+
update!(response: source, next_sequence: frames.last.sequence + 1)
|
|
44
|
+
frames
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def snapshot
|
|
49
|
+
OpenUI::Rails::Stream::Envelope.snapshot(message_id: message_id,
|
|
50
|
+
sequence: next_sequence - 1, response: response,
|
|
51
|
+
library_version: OpenUI::Rails.configuration.library_fingerprint)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.valid_form_state?(state)
|
|
55
|
+
state.is_a?(Hash) &&
|
|
56
|
+
(state.keys - [ "reply" ]).empty? && state.values.all? { |fields|
|
|
57
|
+
fields.is_a?(Hash) && (fields.keys - [ "note" ]).empty? && fields.values.all? { |field|
|
|
58
|
+
field.is_a?(Hash) && (field.keys - %w[value componentType]).empty? &&
|
|
59
|
+
field["value"].is_a?(String) && field["value"].bytesize <= 2000 &&
|
|
60
|
+
(!field.key?("componentType") || field["componentType"] == "Input")
|
|
61
|
+
}
|
|
62
|
+
} && JSON.generate(state).bytesize <= 4096
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def save_form!(state, expected_version:)
|
|
66
|
+
raise OpenUI::Rails::InvalidActionError, "invalid note state" unless self.class.valid_form_state?(state)
|
|
67
|
+
|
|
68
|
+
with_lock do
|
|
69
|
+
check_version!(expected_version)
|
|
70
|
+
update!(form_state: state)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def close_ticket!(invocation)
|
|
75
|
+
with_lock do
|
|
76
|
+
check_version!(invocation.submission.version)
|
|
77
|
+
raise OpenUI::Rails::InvalidActionError, "ticket is already closed" if ticket.reload.status == "closed"
|
|
78
|
+
|
|
79
|
+
Receipt.create!(ticket_id: ticket_id, operation: "close")
|
|
80
|
+
ticket.update!(status: "closed")
|
|
81
|
+
advance!
|
|
82
|
+
end
|
|
83
|
+
{ "version" => version, "status" => "closed" }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def continue!(invocation)
|
|
87
|
+
with_lock do
|
|
88
|
+
check_version!(invocation.submission.version)
|
|
89
|
+
raise OpenUI::Rails::InvalidActionError, "demo turn limit reached" if messages.length >= 20
|
|
90
|
+
|
|
91
|
+
update!(messages: messages + [ { "role" => "user", "text" => invocation.args.fetch("message") } ],
|
|
92
|
+
form_state: invocation.args.fetch("form_state", form_state))
|
|
93
|
+
advance!
|
|
94
|
+
end
|
|
95
|
+
{ "version" => version }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def check_version!(expected)
|
|
101
|
+
raise OpenUI::Rails::StaleSurfaceError, "conversation changed; reload it" unless version == expected
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def advance!
|
|
105
|
+
update!(version: version + 1, response: "", next_sequence: 0)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk # :nodoc:
|
|
4
|
+
class Demo
|
|
5
|
+
def self.seed!
|
|
6
|
+
[ [ "Alice", "north", "agent" ], [ "Bob", "south", "agent" ], [ "Reader", "north", "reader" ] ].each do |name, tenant, role|
|
|
7
|
+
user = User.find_or_create_by!(name: name) { |record| record.tenant_id = tenant; record.role = role }
|
|
8
|
+
next if Conversation.exists?(user_id: user.id)
|
|
9
|
+
|
|
10
|
+
ticket = Ticket.create!(tenant_id: tenant, title: "#{tenant.capitalize} delivery question")
|
|
11
|
+
Conversation.create!(user: user, ticket: ticket, tenant_id: tenant)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Support Desk · OpenUI Rails reference</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= openui_stylesheet_tag %>
|
|
8
|
+
<%= javascript_importmap_tags "support_desk" %>
|
|
9
|
+
<style>
|
|
10
|
+
body { margin: 0; padding: 24px; font: 16px/1.6 system-ui; background: #f6f7f9; color: #17202a; }
|
|
11
|
+
main { max-width: 800px; margin: auto; } h1 { font-size: 28px; } button { cursor: pointer; }
|
|
12
|
+
header { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
|
13
|
+
.notice { padding: 12px; background: #fff3cf; border-radius: 8px; }
|
|
14
|
+
[role="alert"] { color: #a11; } .openui-surface { background: white; padding: 16px; border-radius: 12px; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body><main><%= yield %></main></body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<h1>Support Desk reference host</h1>
|
|
2
|
+
<p class="notice">Synthetic accounts and tickets. Local demo only. No model provider is called.</p>
|
|
3
|
+
<p>Sign in to stream a ticket, save a private draft, continue the conversation, and close the ticket.</p>
|
|
4
|
+
<% %w[Alice Bob Reader].each do |name| %>
|
|
5
|
+
<%= button_to "Sign in as #{name}", session_path, params: { name: name } %>
|
|
6
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<header>
|
|
2
|
+
<h1>Support Desk</h1>
|
|
3
|
+
<span><%= current_actor.name %> · <%= current_actor.tenant_id %> tenant</span>
|
|
4
|
+
<%= button_to "Sign out", session_path, method: :delete %>
|
|
5
|
+
</header>
|
|
6
|
+
<p class="notice">Recorded response, real OpenUI runtime. Your Rails host owns the ticket and conversation.</p>
|
|
7
|
+
<section data-controller="support-desk"
|
|
8
|
+
data-support-desk-stream-url="<%= stream_conversation_path(@conversation) %>"
|
|
9
|
+
data-support-desk-snapshot-url="<%= snapshot_conversation_path(@conversation) %>"
|
|
10
|
+
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
|
+
<%= openui_surface(surface: @conversation.surface, label: "Support ticket") %>
|
|
13
|
+
<div id="desk-envelopes" hidden></div>
|
|
14
|
+
<p role="status" id="desk-status">Ready</p>
|
|
15
|
+
<p role="alert" id="desk-error"></p>
|
|
16
|
+
</section>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Explicitly loaded by the reference host. Never loaded by require "openui/rails".
|
|
4
|
+
module SupportDesk # :nodoc:
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace SupportDesk
|
|
7
|
+
engine_name "support_desk"
|
|
8
|
+
config.root = File.expand_path(__dir__)
|
|
9
|
+
|
|
10
|
+
# The documented local review uses RAILS_ENV=test. Keep the reference's
|
|
11
|
+
# form tags and the shared action endpoint protected in that environment too.
|
|
12
|
+
initializer "support_desk.csrf", before: "action_controller.set_configs" do |app|
|
|
13
|
+
app.config.action_controller.allow_forgery_protection = true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "support_desk.assets", before: "importmap" do |app|
|
|
17
|
+
app.config.assets.paths << root.join("app/javascript")
|
|
18
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
OpenUI::Rails.configure do |config|
|
|
4
|
+
library = ENV.fetch("OPENUI_REFERENCE_LIBRARY_DIR") { Rails.root.join("config/openui") }
|
|
5
|
+
config.library_spec_path = File.join(library, "library.spec.json")
|
|
6
|
+
config.prompt_path = File.join(library, "library.prompt.txt")
|
|
7
|
+
config.actor_resolver = ->(controller) { SupportDesk::User.find_by(id: controller.session[:support_desk_user_id]) }
|
|
8
|
+
config.surface_resolver = ->(id, actor:, **) {
|
|
9
|
+
SupportDesk::Conversation.find_by(id: id, user_id: actor.id, tenant_id: actor.tenant_id) if actor
|
|
10
|
+
}
|
|
11
|
+
config.idempotency_scope = ->(invocation) { [ invocation.actor.tenant_id, invocation.actor.id ] }
|
|
12
|
+
config.authorize_action = ->(invocation) {
|
|
13
|
+
actor, conversation = invocation.actor, invocation.surface
|
|
14
|
+
actor && conversation && conversation.user_id == actor.id && conversation.tenant_id == actor.tenant_id &&
|
|
15
|
+
(invocation.action == "ticket_lookup" || actor.role == "agent")
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
OpenUI::Rails.actions.register "ticket_lookup", schema: {}, handler: ->(invocation) {
|
|
19
|
+
ticket = invocation.surface.ticket
|
|
20
|
+
{ "title" => ticket.title, "status" => ticket.status }
|
|
21
|
+
}
|
|
22
|
+
OpenUI::Rails.actions.register "close_ticket", schema: {}, handler: ->(invocation) {
|
|
23
|
+
invocation.surface.close_ticket!(invocation)
|
|
24
|
+
}
|
|
25
|
+
OpenUI::Rails.actions.register "continue_conversation",
|
|
26
|
+
schema: ->(args) {
|
|
27
|
+
(args.keys - %w[message context form_name form_state]).empty? &&
|
|
28
|
+
args["message"].is_a?(String) && args["message"].bytesize.between?(1, 1000) &&
|
|
29
|
+
(!args.key?("context") || args["context"].is_a?(String) && args["context"].bytesize <= 1000) &&
|
|
30
|
+
(!args.key?("form_name") || args["form_name"] == "reply") &&
|
|
31
|
+
(!args.key?("form_state") || SupportDesk::Conversation.valid_form_state?(args["form_state"]))
|
|
32
|
+
}, handler: ->(invocation) { invocation.surface.continue!(invocation) }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
SupportDesk::Engine.routes.draw do
|
|
2
|
+
root "conversations#index"
|
|
3
|
+
post "/session", to: "sessions#create"
|
|
4
|
+
delete "/session", to: "sessions#destroy"
|
|
5
|
+
resources :conversations, only: [ :show ] do
|
|
6
|
+
member do
|
|
7
|
+
post :stream
|
|
8
|
+
get :snapshot
|
|
9
|
+
patch :state
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateSupportDesk < ActiveRecord::Migration[8.0] # :nodoc:
|
|
4
|
+
def change
|
|
5
|
+
create_table :support_desk_users do |t|
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.string :tenant_id, null: false
|
|
8
|
+
t.string :role, null: false
|
|
9
|
+
end
|
|
10
|
+
add_index :support_desk_users, :name, unique: true
|
|
11
|
+
create_table :support_desk_tickets do |t|
|
|
12
|
+
t.string :tenant_id, null: false
|
|
13
|
+
t.string :title, null: false
|
|
14
|
+
t.string :status, null: false, default: "open"
|
|
15
|
+
end
|
|
16
|
+
create_table :support_desk_conversations do |t|
|
|
17
|
+
t.references :user, null: false, foreign_key: { to_table: :support_desk_users }
|
|
18
|
+
t.references :ticket, null: false, foreign_key: { to_table: :support_desk_tickets }
|
|
19
|
+
t.string :tenant_id, null: false
|
|
20
|
+
t.integer :version, null: false, default: 0
|
|
21
|
+
t.text :response, null: false, default: ""
|
|
22
|
+
t.integer :next_sequence, null: false, default: 0
|
|
23
|
+
t.json :form_state, null: false, default: {}
|
|
24
|
+
t.json :messages, null: false, default: []
|
|
25
|
+
end
|
|
26
|
+
create_table :support_desk_receipts do |t|
|
|
27
|
+
t.references :ticket, null: false, foreign_key: { to_table: :support_desk_tickets }
|
|
28
|
+
t.string :operation, null: false
|
|
29
|
+
end
|
|
30
|
+
# Domain-level guard survives a process restart and idempotency-store expiry.
|
|
31
|
+
add_index :support_desk_receipts, [ :ticket_id, :operation ], unique: true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Explicit reference-host wiring, not a generator and not a general installer.
|
|
4
|
+
require "fileutils"
|
|
5
|
+
root = File.realpath(ARGV.fetch(0, Dir.pwd))
|
|
6
|
+
application = File.join(root, "config/application.rb")
|
|
7
|
+
source = File.read(application)
|
|
8
|
+
marker = "Bundler.require(*Rails.groups)"
|
|
9
|
+
abort "expected a generated full-stack Rails host" unless source.include?(marker)
|
|
10
|
+
line = 'require File.join(Gem.loaded_specs.fetch("openui-rails").full_gem_path, "examples/support_desk/boot")'
|
|
11
|
+
File.write(application, source.sub(marker, "#{marker}\n#{line}")) unless source.include?(line)
|
|
12
|
+
routes = File.join(root, "config/routes.rb")
|
|
13
|
+
route = 'Rails.application.routes.draw { mount SupportDesk::Engine => "/support" }'
|
|
14
|
+
File.open(routes, "a") { |file| file.puts route } unless File.read(routes).include?("SupportDesk::Engine")
|
|
15
|
+
puts "Reference host wired. Install migrations, prepare the default library/runtime, and seed synthetic records as documented."
|
|
@@ -39,13 +39,14 @@ module OpenUI
|
|
|
39
39
|
say "openui-rails installed.", :green
|
|
40
40
|
say ""
|
|
41
41
|
say "Next steps:"
|
|
42
|
-
say " 1.
|
|
43
|
-
say "
|
|
42
|
+
say " 1. Prepare the default chat library (no browser, Node, or provider required):"
|
|
43
|
+
say " bin/rails openui:library:prepare"
|
|
44
44
|
say " 2. Self-host the pinned runtime:"
|
|
45
45
|
say " bin/rails openui:runtime:fetch"
|
|
46
|
-
say " 3.
|
|
47
|
-
say " 4.
|
|
48
|
-
say "
|
|
46
|
+
say " 3. Pin/register the Stimulus controllers and include openui_stylesheet_tag in your layout."
|
|
47
|
+
say " 4. Run bin/rails openui:doctor, then verify rendering in a browser."
|
|
48
|
+
say " 5. Configure scoped surfaces and authorization before enabling actions."
|
|
49
|
+
say " Custom libraries require a matching custom runtime; see config/openui/README.md."
|
|
49
50
|
say ""
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -4,8 +4,8 @@ OpenUI::Rails.configure do |config|
|
|
|
4
4
|
# Transport for incremental updates: :turbo, :action_cable or :none.
|
|
5
5
|
config.transport = :turbo
|
|
6
6
|
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# Run bin/rails openui:library:prepare for the pinned default chat library.
|
|
8
|
+
# Custom prompt/spec files need a matching custom runtime.
|
|
9
9
|
config.library_spec_path = Rails.root.join("config/openui/library.spec.json")
|
|
10
10
|
config.prompt_path = Rails.root.join("config/openui/library.prompt.txt")
|
|
11
11
|
|
|
@@ -4,10 +4,23 @@ This directory holds the **prompt** and **spec** generated from the same,
|
|
|
4
4
|
version-pinned component-library revision. Both must come from one revision so
|
|
5
5
|
the model prompt and the browser component library cannot drift.
|
|
6
6
|
|
|
7
|
-
The default browser bundle contains `openuiChatLibrary` only.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
The default browser bundle contains `openuiChatLibrary` only. Prepare its
|
|
8
|
+
exported files without a browser, Node, or a provider:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
bin/rails openui:library:prepare
|
|
12
|
+
bin/rails openui:runtime:fetch
|
|
13
|
+
bin/rails openui:doctor
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Commit the prompt, spec, and `openui.LICENSE.txt`. Repeated preparation preserves
|
|
17
|
+
identical files. Different existing files stop the task; back them up before
|
|
18
|
+
explicitly replacing them with `bin/rails 'openui:library:prepare[true]'`.
|
|
19
|
+
Outputs must stay within the application root. Keep the upstream license when
|
|
20
|
+
redistributing the exported library.
|
|
21
|
+
|
|
22
|
+
Register the Stimulus controllers and include `openui_stylesheet_tag` before
|
|
23
|
+
rendering. Doctor does not verify browser setup or application authorization.
|
|
11
24
|
|
|
12
25
|
For a custom library, use the pinned CLI (never `@latest`):
|
|
13
26
|
|
|
@@ -22,3 +35,5 @@ commit both files. `OpenUI::Rails` fingerprints the spec; a mismatched
|
|
|
22
35
|
fingerprint is a visible failure rather than a silent re-render.
|
|
23
36
|
|
|
24
37
|
Set `DO_NOT_TRACK=1` in CI as well as `--no-telemetry`.
|
|
38
|
+
|
|
39
|
+
Full guides: https://openui-rails.wangyaodi.com/library-contract/
|