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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/AUDIT.md +24 -4
  3. data/CHANGELOG.md +52 -1
  4. data/CONFIGURATION.md +9 -77
  5. data/CONTRIBUTING.md +29 -1
  6. data/DEVELOPMENT.md +89 -0
  7. data/README.md +50 -439
  8. data/RELEASING.md +30 -8
  9. data/THIRD_PARTY_NOTICES.md +10 -0
  10. data/UPGRADING.md +7 -24
  11. data/app/javascript/openui/rails/openui_controller.js +14 -1
  12. data/examples/support_desk/README.md +18 -0
  13. data/examples/support_desk/app/controllers/support_desk/base_controller.rb +37 -0
  14. data/examples/support_desk/app/controllers/support_desk/conversations_controller.rb +47 -0
  15. data/examples/support_desk/app/controllers/support_desk/sessions_controller.rb +19 -0
  16. data/examples/support_desk/app/javascript/support_desk.js +109 -0
  17. data/examples/support_desk/app/models/support_desk/conversation.rb +108 -0
  18. data/examples/support_desk/app/models/support_desk/demo.rb +15 -0
  19. data/examples/support_desk/app/models/support_desk/receipt.rb +6 -0
  20. data/examples/support_desk/app/models/support_desk/ticket.rb +6 -0
  21. data/examples/support_desk/app/models/support_desk/user.rb +6 -0
  22. data/examples/support_desk/app/views/layouts/support_desk/application.html.erb +18 -0
  23. data/examples/support_desk/app/views/support_desk/conversations/index.html.erb +6 -0
  24. data/examples/support_desk/app/views/support_desk/conversations/show.html.erb +16 -0
  25. data/examples/support_desk/boot.rb +21 -0
  26. data/examples/support_desk/config/importmap.rb +4 -0
  27. data/examples/support_desk/config/initializers/openui.rb +32 -0
  28. data/examples/support_desk/config/routes.rb +12 -0
  29. data/examples/support_desk/db/migrate/20261019000000_create_support_desk.rb +33 -0
  30. data/examples/support_desk/install.rb +15 -0
  31. data/lib/generators/openui/install/install_generator.rb +6 -5
  32. data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +2 -2
  33. data/lib/generators/openui/install/templates/config/openui/README.md +19 -4
  34. data/lib/openui/rails/default_library/library.prompt.txt +198 -0
  35. data/lib/openui/rails/default_library/library.spec.json +4390 -0
  36. data/lib/openui/rails/default_library/openui.LICENSE.txt +21 -0
  37. data/lib/openui/rails/default_library/provenance.json +15 -0
  38. data/lib/openui/rails/default_library.rb +85 -0
  39. data/lib/openui/rails/doctor.rb +143 -0
  40. data/lib/openui/rails/library_manifest.rb +2 -1
  41. data/lib/openui/rails/surface.rb +22 -4
  42. data/lib/openui/rails/version.rb +1 -1
  43. data/lib/openui/rails.rb +2 -0
  44. data/lib/tasks/openui.rake +19 -0
  45. 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: payload.args ?? payload.params ?? {},
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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class Receipt < ActiveRecord::Base
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class Ticket < ActiveRecord::Base
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class User < ActiveRecord::Base
5
+ end
6
+ 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,4 @@
1
+ pin "support_desk", to: "support_desk.js"
2
+ pin "@hotwired/turbo-rails", to: "turbo.min.js"
3
+ pin "@hotwired/stimulus", to: "stimulus.min.js"
4
+ pin_all_from OpenUI::Rails::Engine.root.join("app/javascript/openui"), under: "openui"
@@ -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. Generate the library prompt and spec with the pinned CLI:"
43
- say " npx --yes @openuidev/cli@0.6.0 generate <entry> --out config/openui/library.prompt.txt --no-telemetry"
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. Register actions and an authorizer (see config/initializers/openui.rb)."
47
- say " 4. Pin/register the Stimulus controllers and include openui_stylesheet_tag in your layout."
48
- say " The default bundle requires the exact openuiChatLibrary prompt/spec. See the gem README."
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
- # The prompt and spec generated from the same, pinned library revision by the
8
- # OpenUI CLI. See config/openui/README.md for the exact command.
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. For that bundle,
8
- save the exact outputs of `window.__OpenUI.openuiChatLibrary.prompt()` and
9
- `.toSpec()` as the prompt text and JSON spec. A custom library needs a matching
10
- host-built runtime; the CLI does not change the default browser bundle.
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/