studio-engine 0.122.0 → 0.123.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 +78 -0
- data/README.md +2 -2
- data/app/controllers/concerns/studio/admin_models.rb +24 -16
- data/app/controllers/concerns/studio/error_handling.rb +13 -0
- data/app/controllers/studio/agent_chats_controller.rb +31 -4
- data/app/controllers/studio/voice_takes_controller.rb +11 -0
- data/app/controllers/style_controller.rb +2 -5
- data/app/controllers/theme_settings_controller.rb +8 -8
- data/app/helpers/studio/agent_chat_helper.rb +37 -0
- data/app/javascript/studio/agent_chat.js +42 -2
- data/app/javascript/studio/agent_chat_voice.js +318 -0
- data/app/javascript/studio/alpine_shims.js +1 -3
- data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
- data/app/javascript/studio/microphone_chip.js +1 -0
- data/app/javascript/studio/stimulus.js +3 -0
- data/app/javascript/studio/style_guide.js +2 -0
- data/app/javascript/studio/style_section_nav.js +16 -0
- data/app/javascript/studio/theme_editor.js +6 -106
- data/app/javascript/studio/voice_recorder.js +23 -2
- data/app/models/studio/user_session.rb +76 -0
- data/app/models/theme_setting.rb +3 -0
- data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
- data/app/views/studio/admin_models/show.html.erb +22 -11
- data/app/views/studio/agent_chat/_chat.html.erb +17 -2
- data/app/views/studio/agent_chat/_form.html.erb +3 -2
- data/app/views/studio/agent_chat/_voice.html.erb +54 -0
- data/app/views/studio/voice/_microphone_help.html.erb +6 -3
- data/app/views/style/_theme.html.erb +75 -12
- data/app/views/style/index.html.erb +28 -30
- data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
- data/docs/SITE_FOOTER.md +33 -0
- data/lib/studio/agent_chat.rb +83 -5
- data/lib/studio/site_footer.rb +45 -2
- data/lib/studio/ui_primitives.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +64 -6
- metadata +11 -4
- data/app/views/studio/admin_models/index.html.erb +0 -31
- data/app/views/theme_settings/edit.html.erb +0 -304
data/lib/studio/agent_chat.rb
CHANGED
|
@@ -16,8 +16,8 @@ module Studio
|
|
|
16
16
|
#
|
|
17
17
|
# THE CONTRACT (.agents/epics/agent-chat-primitive.md, "Contract"):
|
|
18
18
|
#
|
|
19
|
-
# POST /api/v1/agent_chat/messages soul, text, person, ip
|
|
20
|
-
# 201 {turn} · 422 {error} · 429 capped · 404 soul has no web chat
|
|
19
|
+
# POST /api/v1/agent_chat/messages soul, text, person, ip, voice (optional)
|
|
20
|
+
# 201 {turn, voice} · 422 {error} · 429 capped · 404 soul has no web chat
|
|
21
21
|
# GET /api/v1/agent_chat/turns soul, person, after, limit (<= 50)
|
|
22
22
|
# 200 {turns: [...], pending: bool}
|
|
23
23
|
#
|
|
@@ -27,6 +27,11 @@ module Studio
|
|
|
27
27
|
# {id, role: "user"|"agent", text, created_at, status} with status
|
|
28
28
|
# pending|typing|answered|failed.
|
|
29
29
|
#
|
|
30
|
+
# `voice` marks a spoken message: {check, score, duration_ms}, what POST
|
|
31
|
+
# /voice/takes answered for the take (voice_param). NEVER A NAME: the hub
|
|
32
|
+
# names the speaker itself (the verified person's own name when check is
|
|
33
|
+
# "matched", else "Someone new") and may answer what it kept as `voice`.
|
|
34
|
+
#
|
|
30
35
|
# AUTHENTICATION is the APP's own bearer credential (Studio.agent_chat_token,
|
|
31
36
|
# else ENV["AGENT_CHAT_TOKEN"]), never a user's. It is sent only over TLS: a
|
|
32
37
|
# plain-http hub URL is refused unless its host is this machine (a developer
|
|
@@ -83,6 +88,31 @@ module Studio
|
|
|
83
88
|
MAX_TEXT_LENGTH = 4_000
|
|
84
89
|
# A hub answer larger than this is not a chat answer.
|
|
85
90
|
MAX_BODY_BYTES = 1_048_576
|
|
91
|
+
# The words beside the microphone, approved by Alex McRitchie on 2026-11-01.
|
|
92
|
+
# Verbatim here and on an app's privacy page, from this one constant.
|
|
93
|
+
VOICE_PRIVACY = "When you speak to an agent, your recording is sent to OpenAI to turn it " \
|
|
94
|
+
"into text, then deleted. We never keep the audio."
|
|
95
|
+
|
|
96
|
+
# The hold-to-talk button's words per recorder state (merged over
|
|
97
|
+
# Studio::VoiceCapture::RECORDER_COPY). The recorder says nothing while it
|
|
98
|
+
# records: the chat's own status line says "Listening...", so the button does
|
|
99
|
+
# not move under a held finger when the line appears.
|
|
100
|
+
VOICE_RECORDER_COPY = {
|
|
101
|
+
"idle" => { "button" => "Hold to talk" },
|
|
102
|
+
"recording" => { "button" => "Let go to send", "status" => "", "heard" => "" },
|
|
103
|
+
"captured" => { "button" => "Hold to talk", "status" => "" },
|
|
104
|
+
"silent" => { "button" => "Hold to talk" },
|
|
105
|
+
"failed" => { "button" => "Hold to try again" },
|
|
106
|
+
"denied" => { "button" => "Hold to talk" },
|
|
107
|
+
"unsupported" => { "button" => "Hold to talk" }
|
|
108
|
+
}.freeze
|
|
109
|
+
|
|
110
|
+
# The microphone chip's first words (the engine's say "tap Record").
|
|
111
|
+
VOICE_CHIP_COPY = {
|
|
112
|
+
"prompt" => { "chip" => "Microphone: hold to talk to allow",
|
|
113
|
+
"note" => "Hold the talk button once and allow the microphone. Then you can pick one here." }
|
|
114
|
+
}.freeze
|
|
115
|
+
|
|
86
116
|
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
|
|
87
117
|
NETWORK_ERRORS = [
|
|
88
118
|
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error,
|
|
@@ -93,13 +123,49 @@ module Studio
|
|
|
93
123
|
class << self
|
|
94
124
|
# Stores one message from `person` to `soul` and answers its turn (a Hash
|
|
95
125
|
# with string keys). Raises a Studio::AgentChat::Error.
|
|
96
|
-
|
|
97
|
-
|
|
126
|
+
#
|
|
127
|
+
# `voice` marks it as spoken (voice_param). When the hub answers what it
|
|
128
|
+
# kept, the returned Hash also carries it under "voice".
|
|
129
|
+
def post_message(soul:, text:, person:, ip: nil, voice: nil)
|
|
130
|
+
body = { soul: soul.to_s, text: text.to_s, person: person_param(person), ip: ip.to_s.presence,
|
|
131
|
+
voice: voice_param(voice) }.compact
|
|
98
132
|
payload = request(:post, MESSAGES_PATH, body: body)
|
|
99
133
|
turn = payload.is_a?(Hash) && payload["turn"].is_a?(Hash) ? payload["turn"] : payload
|
|
100
134
|
raise Unavailable.new("the hub answered with no turn", status: 201) unless turn.is_a?(Hash)
|
|
101
135
|
|
|
102
|
-
|
|
136
|
+
kept = payload["voice"] if payload.is_a?(Hash)
|
|
137
|
+
kept.is_a?(Hash) ? turn.merge("voice" => kept) : turn
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The words of the chat's voice option: {"privacy", "recorder", "chip"},
|
|
141
|
+
# the `overrides` (voice_copy: on studio/agent_chat/chat) over the defaults.
|
|
142
|
+
# A blank privacy line is the default one: the line is never just dropped.
|
|
143
|
+
def voice_copy(overrides = nil)
|
|
144
|
+
words = (overrides || {}).to_h.transform_keys(&:to_s)
|
|
145
|
+
{
|
|
146
|
+
"privacy" => words["privacy"].presence || VOICE_PRIVACY,
|
|
147
|
+
"recorder" => Studio::VoiceCapture.merge(VOICE_RECORDER_COPY, words["recorder"]),
|
|
148
|
+
"chip" => Studio::VoiceCapture.merge(VOICE_CHIP_COPY, words["chip"])
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# What a message's `voice` may say to the hub, as the hub reads it: `check`
|
|
153
|
+
# is one of the engine's own (else Invalid), `score` a cosine (-1..1, 4
|
|
154
|
+
# places) or nil, `duration_ms` 1..MAX_DURATION_MS or nil. Everything else,
|
|
155
|
+
# a browser-chosen name above all, stops here. nil or "" is a typed message.
|
|
156
|
+
def voice_param(voice)
|
|
157
|
+
return nil if voice.nil? || voice == ""
|
|
158
|
+
|
|
159
|
+
voice = voice.to_unsafe_h if voice.respond_to?(:to_unsafe_h)
|
|
160
|
+
raise Invalid.new("voice must be {check, score, duration_ms}", status: 422) unless voice.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
voice = voice.transform_keys(&:to_s)
|
|
163
|
+
check = voice["check"].to_s
|
|
164
|
+
unless Studio::VoiceTranscription::CHECKS.include?(check)
|
|
165
|
+
raise Invalid.new("voice.check is not one the engine answers", status: 422)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
{ check: check, score: voice_score(voice["score"]), duration_ms: voice_duration(voice["duration_ms"]) }
|
|
103
169
|
end
|
|
104
170
|
|
|
105
171
|
# The thread's turns after `after` (a turn id; nil for the latest), and
|
|
@@ -118,6 +184,18 @@ module Studio
|
|
|
118
184
|
|
|
119
185
|
private
|
|
120
186
|
|
|
187
|
+
def voice_score(raw)
|
|
188
|
+
return nil unless raw.is_a?(Numeric) || raw.is_a?(String)
|
|
189
|
+
|
|
190
|
+
value = Float(raw, exception: false)
|
|
191
|
+
value&.finite? ? value.clamp(-1.0, 1.0).round(4) : nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def voice_duration(raw)
|
|
195
|
+
value = raw.to_s.match?(/\A\d{1,9}\z/) ? raw.to_s.to_i : nil
|
|
196
|
+
value&.between?(1, Studio::VoiceTranscription::MAX_DURATION_MS) ? value : nil
|
|
197
|
+
end
|
|
198
|
+
|
|
121
199
|
# {email:}, {anon_token:}, or both: a signed-in post that still holds the
|
|
122
200
|
# visitor's anon token sends both, and the hub folds that anonymous thread
|
|
123
201
|
# into the person's.
|
data/lib/studio/site_footer.rb
CHANGED
|
@@ -170,7 +170,8 @@ module Studio
|
|
|
170
170
|
# booking_path: the booking page's path (Studio.booking_path_for), or nil
|
|
171
171
|
# address: Studio.site_footer_address: the address of facts that
|
|
172
172
|
# carry none of their own
|
|
173
|
-
|
|
173
|
+
# chat: the Chat entry's [label, path] (`chat_row`), or nil
|
|
174
|
+
def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil, chat: nil)
|
|
174
175
|
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
175
176
|
return nil if raw.nil? || raw == false
|
|
176
177
|
|
|
@@ -189,7 +190,7 @@ module Studio
|
|
|
189
190
|
email: text(facts[:email]),
|
|
190
191
|
address: address_for(facts, address),
|
|
191
192
|
social: Array(facts[:social]).filter_map { |row| social(row) },
|
|
192
|
-
columns: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
|
|
193
|
+
columns: with_chat(Array(facts[:columns]).filter_map { |row| column(row, booking_path) }, chat),
|
|
193
194
|
legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
|
|
194
195
|
booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
|
|
195
196
|
booking_title: text(booking[:title])
|
|
@@ -374,6 +375,48 @@ module Studio
|
|
|
374
375
|
{ heading: heading, links: links, width: column_width(options[:width]) }
|
|
375
376
|
end
|
|
376
377
|
|
|
378
|
+
# The column the standard Chat entry joins, made if the app has none.
|
|
379
|
+
CONTACT_HEADING = "Contact"
|
|
380
|
+
|
|
381
|
+
# The Chat entry for this viewer as a [label, path] row, or nil: no path
|
|
382
|
+
# means the app has no chat, and `allowed` (a callable taking the viewer, nil
|
|
383
|
+
# when signed out) can hide it, e.g. admins only while testing. A hook that
|
|
384
|
+
# raises hides the entry and logs; the footer is on every page, and a link
|
|
385
|
+
# meant for a few must not show because its gate broke.
|
|
386
|
+
def chat_row(view, path:, label: nil, allowed: nil)
|
|
387
|
+
href = path.respond_to?(:call) ? path.call(view) : path
|
|
388
|
+
return nil if text(href).nil?
|
|
389
|
+
return nil unless chat_allowed?(view, allowed)
|
|
390
|
+
|
|
391
|
+
[text(label) || "Chat", href]
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def chat_allowed?(view, allowed)
|
|
395
|
+
return true if allowed.nil?
|
|
396
|
+
|
|
397
|
+
user = view.respond_to?(:current_user) ? view.current_user : nil
|
|
398
|
+
(allowed.respond_to?(:call) ? allowed.call(user) : allowed) ? true : false
|
|
399
|
+
rescue StandardError => error
|
|
400
|
+
SiteFooter.reporter.call("[studio.site_footer] site_footer_chat_allowed raised #{error.class}: the Chat link is hidden")
|
|
401
|
+
false
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# `columns` with the Chat entry last in the Contact column (any case), which
|
|
405
|
+
# is added at the end if there is none. A refused href adds nothing, and an
|
|
406
|
+
# app that already links the same path keeps its own row.
|
|
407
|
+
def with_chat(columns, chat)
|
|
408
|
+
row = chat && link(chat)
|
|
409
|
+
return columns if row.nil? || row[:href].nil?
|
|
410
|
+
|
|
411
|
+
at = columns.index { |column| column[:heading].to_s.casecmp?(CONTACT_HEADING) }
|
|
412
|
+
return columns + [{ heading: CONTACT_HEADING, links: [row], width: nil }] if at.nil?
|
|
413
|
+
return columns if columns[at][:links].any? { |known| known[:href] == row[:href] }
|
|
414
|
+
|
|
415
|
+
columns.each_with_index.map do |column, index|
|
|
416
|
+
index == at ? column.merge(links: column[:links] + [row]) : column
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
377
420
|
# A column's width hint, or nil for the default share. Anything that is not a
|
|
378
421
|
# number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
|
|
379
422
|
MIN_COLUMN_WIDTH = 0.5
|
data/lib/studio/ui_primitives.rb
CHANGED
|
@@ -10,8 +10,10 @@ module Studio
|
|
|
10
10
|
align-items: center;
|
|
11
11
|
justify-content: center;
|
|
12
12
|
vertical-align: middle;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/* In em so a larger glyph fits: at text-lg on a 16px scale a fixed 1.25rem
|
|
14
|
+
box clipped 4px off a 24px emoji. The floor keeps the navbar size. */
|
|
15
|
+
width: max(1.5rem, 1.25em);
|
|
16
|
+
height: max(1.25rem, 1.25em);
|
|
15
17
|
overflow: hidden;
|
|
16
18
|
}
|
|
17
19
|
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -1471,7 +1471,7 @@ module Studio
|
|
|
1471
1471
|
mattr_accessor :agent_chat_souls, default: []
|
|
1472
1472
|
mattr_accessor :agent_chat_hub_url, default: "https://mcritchie.studio"
|
|
1473
1473
|
# The app's bearer credential for the hub. Leave nil to read
|
|
1474
|
-
# ENV["AGENT_CHAT_TOKEN"] (1Password
|
|
1474
|
+
# ENV["AGENT_CHAT_TOKEN"] (1Password agent-chat.<slug>.applications).
|
|
1475
1475
|
mattr_accessor :agent_chat_token, default: nil
|
|
1476
1476
|
# Which signed-in users the hub may know by email. An email the user only
|
|
1477
1477
|
# CLAIMS (the profile page saves a new address and clears email_verified_at;
|
|
@@ -1486,6 +1486,50 @@ module Studio
|
|
|
1486
1486
|
Array(agent_chat_souls).map { |soul| soul.to_s.strip }.reject(&:empty?).uniq
|
|
1487
1487
|
end
|
|
1488
1488
|
|
|
1489
|
+
# Who may use the chat at all: a callable taking the user, nil when signed out.
|
|
1490
|
+
# The server's gate, not only the look: Studio::AgentChatsController answers a
|
|
1491
|
+
# refused viewer 403 on both calls before any hub call, and the chat partial
|
|
1492
|
+
# renders nothing for them. The footer's Chat link follows it too unless
|
|
1493
|
+
# site_footer_chat_allowed is set, so one setting covers both. Default: everyone
|
|
1494
|
+
# (a public chat). While testing:
|
|
1495
|
+
#
|
|
1496
|
+
# config.agent_chat_allowed = ->(user) { user&.admin? }
|
|
1497
|
+
mattr_accessor :agent_chat_allowed, default: ->(_user) { true }
|
|
1498
|
+
|
|
1499
|
+
# The one reading of Studio.agent_chat_allowed. A hook that raises answers false
|
|
1500
|
+
# and logs: `user.admin?` on a signed-out visitor closes the chat, never opens it.
|
|
1501
|
+
def self.agent_chat_allowed?(user)
|
|
1502
|
+
hook = agent_chat_allowed
|
|
1503
|
+
(hook.respond_to?(:call) ? hook.call(user) : hook) ? true : false
|
|
1504
|
+
rescue StandardError => e
|
|
1505
|
+
Rails.logger&.warn("[studio] agent_chat_allowed raised #{e.class}: treated as not allowed") if defined?(Rails.logger)
|
|
1506
|
+
false
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1509
|
+
# Who may talk by voice (docs/AGENT_CHAT.md, "Talking by voice"): a callable
|
|
1510
|
+
# taking the signed-in user. It decides who sees the chat's microphone, who
|
|
1511
|
+
# may post a take to POST /voice/takes (each one is a paid vendor call), and
|
|
1512
|
+
# whether the proxy forwards a message's `voice`. A signed-out visitor is
|
|
1513
|
+
# never asked about: no user, no voice. While testing, an app narrows it:
|
|
1514
|
+
#
|
|
1515
|
+
# config.voice_chat_allowed = ->(user) { user.admin? }
|
|
1516
|
+
#
|
|
1517
|
+
# A user Studio.agent_chat_allowed refuses may not talk either, whatever this says.
|
|
1518
|
+
mattr_accessor :voice_chat_allowed, default: ->(user) { user.present? }
|
|
1519
|
+
|
|
1520
|
+
# The one reading of Studio.voice_chat_allowed. False for no user, and for a user
|
|
1521
|
+
# the chat itself refuses, whatever the hook says. A hook that raises answers false and logs: the safe answer on a
|
|
1522
|
+
# paid, sign-in-only control, and a page never falls over because of it.
|
|
1523
|
+
def self.voice_chat_allowed?(user)
|
|
1524
|
+
return false if user.nil? || !agent_chat_allowed?(user)
|
|
1525
|
+
|
|
1526
|
+
hook = voice_chat_allowed
|
|
1527
|
+
(hook.respond_to?(:call) ? hook.call(user) : hook) ? true : false
|
|
1528
|
+
rescue StandardError => e
|
|
1529
|
+
Rails.logger&.warn("[studio] voice_chat_allowed raised #{e.class}: treated as not allowed") if defined?(Rails.logger)
|
|
1530
|
+
false
|
|
1531
|
+
end
|
|
1532
|
+
|
|
1489
1533
|
class S3ConfigError < StandardError; end
|
|
1490
1534
|
|
|
1491
1535
|
# Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
|
|
@@ -1733,6 +1777,20 @@ module Studio
|
|
|
1733
1777
|
SidebarSections.resolve(sidebar_sections, view)
|
|
1734
1778
|
end
|
|
1735
1779
|
|
|
1780
|
+
# The standard Chat entry in the footer's Contact column (docs/SITE_FOOTER.md,
|
|
1781
|
+
# "The Chat link"). OFF until the app names a path: no path, no entry.
|
|
1782
|
+
#
|
|
1783
|
+
# config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_path }
|
|
1784
|
+
# config.site_footer_chat_label = "Chat" # the default
|
|
1785
|
+
# config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
|
|
1786
|
+
#
|
|
1787
|
+
# site_footer_chat_allowed takes the viewer, nil when signed out; a hook that
|
|
1788
|
+
# raises hides the entry and logs (SiteFooter.chat_row). Unset, the link follows
|
|
1789
|
+
# Studio.agent_chat_allowed, so the link shows to whom the chat answers.
|
|
1790
|
+
mattr_accessor :site_footer_chat_path, default: nil
|
|
1791
|
+
mattr_accessor :site_footer_chat_label, default: "Chat"
|
|
1792
|
+
mattr_accessor :site_footer_chat_allowed, default: nil
|
|
1793
|
+
|
|
1736
1794
|
# The site footer's facts resolved for a view (lib/studio/site_footer.rb), or
|
|
1737
1795
|
# nil when the app turned the footer off (or its own callable answered nil).
|
|
1738
1796
|
# Unset, the facts are the default footer's. The name defaults to the site
|
|
@@ -1744,7 +1802,10 @@ module Studio
|
|
|
1744
1802
|
|
|
1745
1803
|
declared = site_footer.nil? ? SiteFooter.default_facts(view, routes: host_route_set) : site_footer
|
|
1746
1804
|
SiteFooter.resolve(declared, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
|
|
1747
|
-
booking_path: booking_path_for(view), address: site_footer_address
|
|
1805
|
+
booking_path: booking_path_for(view), address: site_footer_address,
|
|
1806
|
+
chat: SiteFooter.chat_row(view, path: site_footer_chat_path,
|
|
1807
|
+
label: site_footer_chat_label,
|
|
1808
|
+
allowed: site_footer_chat_allowed.nil? ? agent_chat_allowed : site_footer_chat_allowed))
|
|
1748
1809
|
end
|
|
1749
1810
|
|
|
1750
1811
|
# The host's route set, for the default footer's legal links. nil outside a
|
|
@@ -2051,11 +2112,8 @@ module Studio
|
|
|
2051
2112
|
get "schedule", to: "studio/bookings#show", as: :studio_booking
|
|
2052
2113
|
end
|
|
2053
2114
|
|
|
2054
|
-
# The living style guide
|
|
2055
|
-
# /admin/design_system redirects here but KEEPS its admin_design_system_path
|
|
2056
|
-
# helper so a shipped host sidebar link on the old helper still resolves.
|
|
2115
|
+
# The living style guide (StyleController#index).
|
|
2057
2116
|
get "admin/style", to: "style#index", as: :admin_style
|
|
2058
|
-
get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
|
|
2059
2117
|
|
|
2060
2118
|
# The component gallery: Lookbook over the engine's ViewComponent previews.
|
|
2061
2119
|
# The router refuses anyone but a signed-in admin, so a visitor and a
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.123.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -477,6 +477,7 @@ files:
|
|
|
477
477
|
- app/controllers/style_controller.rb
|
|
478
478
|
- app/controllers/theme_settings_controller.rb
|
|
479
479
|
- app/helpers/studio/admin_models_table_helper.rb
|
|
480
|
+
- app/helpers/studio/agent_chat_helper.rb
|
|
480
481
|
- app/helpers/studio/at_time_helper.rb
|
|
481
482
|
- app/helpers/studio/fizz_helper.rb
|
|
482
483
|
- app/helpers/studio/geo_helper.rb
|
|
@@ -489,6 +490,7 @@ files:
|
|
|
489
490
|
- app/helpers/studio_sidebar_helper.rb
|
|
490
491
|
- app/helpers/studio_theme_helper.rb
|
|
491
492
|
- app/javascript/studio/agent_chat.js
|
|
493
|
+
- app/javascript/studio/agent_chat_voice.js
|
|
492
494
|
- app/javascript/studio/alpine_scopes.js
|
|
493
495
|
- app/javascript/studio/alpine_shims.js
|
|
494
496
|
- app/javascript/studio/alpine_stores.js
|
|
@@ -498,6 +500,7 @@ files:
|
|
|
498
500
|
- app/javascript/studio/booking.js
|
|
499
501
|
- app/javascript/studio/confirm_interstitial.js
|
|
500
502
|
- app/javascript/studio/controllers/agent_chat_controller.js
|
|
503
|
+
- app/javascript/studio/controllers/agent_chat_voice_controller.js
|
|
501
504
|
- app/javascript/studio/controllers/board_controller.js
|
|
502
505
|
- app/javascript/studio/controllers/booking_controller.js
|
|
503
506
|
- app/javascript/studio/controllers/email_banner_scale_controller.js
|
|
@@ -512,6 +515,7 @@ files:
|
|
|
512
515
|
- app/javascript/studio/controllers/modal_host_controller.js
|
|
513
516
|
- app/javascript/studio/controllers/nav_collapse_controller.js
|
|
514
517
|
- app/javascript/studio/controllers/style_modals_controller.js
|
|
518
|
+
- app/javascript/studio/controllers/style_section_nav_controller.js
|
|
515
519
|
- app/javascript/studio/controllers/survey_controller.js
|
|
516
520
|
- app/javascript/studio/controllers/toast_controller.js
|
|
517
521
|
- app/javascript/studio/controllers/voice_profile_controller.js
|
|
@@ -548,6 +552,7 @@ files:
|
|
|
548
552
|
- app/javascript/studio/studio_confetti.js
|
|
549
553
|
- app/javascript/studio/style_guide.js
|
|
550
554
|
- app/javascript/studio/style_modals.js
|
|
555
|
+
- app/javascript/studio/style_section_nav.js
|
|
551
556
|
- app/javascript/studio/survey.js
|
|
552
557
|
- app/javascript/studio/theme_editor.js
|
|
553
558
|
- app/javascript/studio/toast.js
|
|
@@ -581,6 +586,7 @@ files:
|
|
|
581
586
|
- app/models/studio/model_page.rb
|
|
582
587
|
- app/models/studio/site_identity.rb
|
|
583
588
|
- app/models/studio/survey_response.rb
|
|
589
|
+
- app/models/studio/user_session.rb
|
|
584
590
|
- app/models/theme_setting.rb
|
|
585
591
|
- app/services/google_oauth_validator.rb
|
|
586
592
|
- app/services/studio/banner.rb
|
|
@@ -631,8 +637,8 @@ files:
|
|
|
631
637
|
- app/views/studio/_leveling_activity_assets.html.erb
|
|
632
638
|
- app/views/studio/_session_stamp.html.erb
|
|
633
639
|
- app/views/studio/admin_models/_arenas_table.html.erb
|
|
640
|
+
- app/views/studio/admin_models/_sessions_table.html.erb
|
|
634
641
|
- app/views/studio/admin_models/_teams_table.html.erb
|
|
635
|
-
- app/views/studio/admin_models/index.html.erb
|
|
636
642
|
- app/views/studio/admin_models/show.html.erb
|
|
637
643
|
- app/views/studio/admin_surveys/index.html.erb
|
|
638
644
|
- app/views/studio/admin_surveys/show.html.erb
|
|
@@ -641,6 +647,7 @@ files:
|
|
|
641
647
|
- app/views/studio/agent_chat/_header.html.erb
|
|
642
648
|
- app/views/studio/agent_chat/_turn.html.erb
|
|
643
649
|
- app/views/studio/agent_chat/_typing.html.erb
|
|
650
|
+
- app/views/studio/agent_chat/_voice.html.erb
|
|
644
651
|
- app/views/studio/banners/_app_banner.html.erb
|
|
645
652
|
- app/views/studio/banners/_button.html.erb
|
|
646
653
|
- app/views/studio/banners/_dev_mode_button.html.erb
|
|
@@ -796,7 +803,6 @@ files:
|
|
|
796
803
|
- app/views/style/modals/_unsubscribe_confirm.html.erb
|
|
797
804
|
- app/views/style/modals/_wallet_connect.html.erb
|
|
798
805
|
- app/views/style/modals/_wallet_connect_slot.html.erb
|
|
799
|
-
- app/views/theme_settings/edit.html.erb
|
|
800
806
|
- app/views/user_mailer/magic_link.html.erb
|
|
801
807
|
- app/views/user_mailer/magic_link.text.erb
|
|
802
808
|
- config/importmap.rb
|
|
@@ -821,6 +827,7 @@ files:
|
|
|
821
827
|
- db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb
|
|
822
828
|
- db/migrate/20261010120000_add_stored_in_to_image_caches.rb
|
|
823
829
|
- db/migrate/20261029120000_add_voice_profile_to_users.rb
|
|
830
|
+
- db/migrate/20261103120000_create_studio_user_sessions.rb
|
|
824
831
|
- docs/BOOKING.md
|
|
825
832
|
- docs/SITE_FOOTER.md
|
|
826
833
|
- lib/active_storage/service/studio_trash_s3_service.rb
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
<% content_for(:title, "Models - Admin") %>
|
|
2
|
-
|
|
3
|
-
<div class="max-w-7xl mx-auto px-4 py-8 space-y-6">
|
|
4
|
-
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
5
|
-
<div>
|
|
6
|
-
<p class="text-xs text-muted uppercase tracking-wide font-semibold">Admin QA</p>
|
|
7
|
-
<h1 class="text-heading text-2xl font-bold mt-1">Models</h1>
|
|
8
|
-
<p class="text-muted text-sm mt-1">Ten-row snapshots for records that need quick inspection.</p>
|
|
9
|
-
</div>
|
|
10
|
-
<%= link_to "Back to dashboard", admin_dashboard_path, class: "btn btn-outline" %>
|
|
11
|
-
</header>
|
|
12
|
-
|
|
13
|
-
<div class="space-y-5">
|
|
14
|
-
<% @sections.each do |section| %>
|
|
15
|
-
<section id="model-<%= section.fetch(:key) %>" class="card overflow-hidden">
|
|
16
|
-
<div class="px-4 py-4 border-b border-subtle flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
17
|
-
<div>
|
|
18
|
-
<h2 class="text-heading text-lg font-semibold"><%= section.fetch(:label) %></h2>
|
|
19
|
-
<p class="text-xs text-muted"><%= section.fetch(:description) %></p>
|
|
20
|
-
</div>
|
|
21
|
-
<div class="flex items-center gap-3">
|
|
22
|
-
<span class="text-xs text-muted">Showing <%= section.fetch(:records).size %> of <%= section.fetch(:count) %></span>
|
|
23
|
-
<%= link_to "View all", admin_model_path(section.fetch(:key)), class: "text-xs text-primary hover:underline" %>
|
|
24
|
-
</div>
|
|
25
|
-
</div>
|
|
26
|
-
|
|
27
|
-
<%= render admin_models_table_partial(section.fetch(:key)), records: section.fetch(:records) %>
|
|
28
|
-
</section>
|
|
29
|
-
<% end %>
|
|
30
|
-
</div>
|
|
31
|
-
</div>
|