studio-engine 0.122.0 → 0.124.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +142 -0
  3. data/README.md +18 -3
  4. data/app/assets/tailwind/studio_engine/engine.css +210 -11
  5. data/app/controllers/concerns/studio/admin_models.rb +24 -16
  6. data/app/controllers/concerns/studio/error_handling.rb +13 -0
  7. data/app/controllers/studio/agent_chats_controller.rb +31 -4
  8. data/app/controllers/studio/marketing_kit_controller.rb +24 -0
  9. data/app/controllers/studio/voice_takes_controller.rb +11 -0
  10. data/app/controllers/style_controller.rb +2 -5
  11. data/app/controllers/theme_settings_controller.rb +8 -8
  12. data/app/helpers/studio/agent_chat_helper.rb +37 -0
  13. data/app/helpers/studio/site_footer_helper.rb +15 -2
  14. data/app/javascript/studio/agent_chat.js +42 -2
  15. data/app/javascript/studio/agent_chat_voice.js +318 -0
  16. data/app/javascript/studio/alpine_shims.js +1 -3
  17. data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
  18. data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
  19. data/app/javascript/studio/controllers/page_header_controller.js +60 -0
  20. data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
  21. data/app/javascript/studio/microphone_chip.js +1 -0
  22. data/app/javascript/studio/page_header.js +24 -0
  23. data/app/javascript/studio/page_transitions.js +181 -0
  24. data/app/javascript/studio/pinned_stack.js +12 -1
  25. data/app/javascript/studio/stimulus.js +9 -1
  26. data/app/javascript/studio/style_guide.js +2 -0
  27. data/app/javascript/studio/style_section_nav.js +16 -0
  28. data/app/javascript/studio/theme_editor.js +6 -106
  29. data/app/javascript/studio/voice_recorder.js +23 -2
  30. data/app/models/studio/user_session.rb +76 -0
  31. data/app/models/theme_setting.rb +3 -0
  32. data/app/views/components/_link_sidebar.html.erb +5 -2
  33. data/app/views/components/_user_nav.html.erb +5 -4
  34. data/app/views/layouts/_navbar.html.erb +7 -4
  35. data/app/views/layouts/studio/_head.html.erb +6 -0
  36. data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
  37. data/app/views/studio/_page_header.html.erb +65 -0
  38. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  39. data/app/views/studio/admin_models/show.html.erb +15 -12
  40. data/app/views/studio/admin_surveys/index.html.erb +2 -2
  41. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  42. data/app/views/studio/agent_chat/_form.html.erb +4 -3
  43. data/app/views/studio/agent_chat/_header.html.erb +1 -1
  44. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  45. data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
  46. data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
  47. data/app/views/studio/marketing_kit/show.html.erb +6 -0
  48. data/app/views/studio/site_footer/_assets.html.erb +1 -0
  49. data/app/views/studio/site_footer/_footer.html.erb +5 -0
  50. data/app/views/studio/voice/_microphone_help.html.erb +6 -3
  51. data/app/views/style/_theme.html.erb +75 -12
  52. data/app/views/style/_tricks.html.erb +35 -0
  53. data/app/views/style/index.html.erb +21 -34
  54. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  55. data/docs/SITE_FOOTER.md +146 -2
  56. data/lib/studio/agent_chat.rb +83 -5
  57. data/lib/studio/marketing_kit.rb +304 -0
  58. data/lib/studio/site_footer.rb +65 -6
  59. data/lib/studio/ui_primitives.rb +4 -2
  60. data/lib/studio/version.rb +1 -1
  61. data/lib/studio.rb +146 -6
  62. metadata +21 -4
  63. data/app/views/studio/admin_models/index.html.erb +0 -31
  64. data/app/views/theme_settings/edit.html.erb +0 -304
@@ -5,12 +5,18 @@ module Studio
5
5
  # to its own app; this controller forwards its two calls to the hub through
6
6
  # Studio::AgentChat:
7
7
  #
8
- # POST /agent_chat/messages soul, text -> 201 {turn}
8
+ # POST /agent_chat/messages soul, text, voice -> 201 {turn, voice}
9
9
  # GET /agent_chat/turns soul, after, limit -> 200 {turns, pending}
10
10
  #
11
11
  # Drawn by Studio.routes only for an app that names a soul
12
12
  # (Studio.agent_chat_souls).
13
13
  #
14
+ # WHO MAY USE IT: Studio.agent_chat_allowed, asked of the viewer on both calls.
15
+ # A refused viewer is a JSON 403 {error, reason: "not_allowed"} before any hub
16
+ # call and before an anon cookie is minted. These routes skip sign-in, so hiding
17
+ # a link is not the gate; with the app's token set the hub answers whoever gets
18
+ # through here.
19
+ #
14
20
  # WHAT THE BROWSER MAY CHOOSE, AND WHAT IT MAY NOT:
15
21
  #
16
22
  # soul only from the app's allow-list (Studio.agent_chat_souls); off the
@@ -27,6 +33,10 @@ module Studio
27
33
  # POST that still holds the cookie sends both, so the hub folds the
28
34
  # anonymous thread into the person's; the cookie is then deleted.
29
35
  # ip never: request.remote_ip, which the hub caps on.
36
+ # voice only what a take measured, {check, score, duration_ms}
37
+ # (Studio::AgentChat.voice_param), and only from a user
38
+ # Studio.voice_chat_allowed? lets talk; anyone else's words go as
39
+ # typed. Never a name: the hub names the speaker.
30
40
  #
31
41
  # The host's ApplicationController is the base, so its session, current_user
32
42
  # and CSRF protection hold: the POST must carry the page's authenticity token
@@ -43,8 +53,10 @@ module Studio
43
53
  ANON_COOKIE = :studio_agent_chat_anon
44
54
  ANON_TTL = 1.year
45
55
  UNAVAILABLE = "Can't reach the chat right now. Try again in a moment."
56
+ NOT_ALLOWED = "This chat isn't open to you yet."
46
57
 
47
58
  before_action :no_store
59
+ before_action :require_chat_allowed
48
60
  before_action :resolve_soul
49
61
 
50
62
  def create
@@ -56,11 +68,13 @@ module Studio
56
68
  end
57
69
 
58
70
  who = person(mint: true, fold: true)
59
- turn = Studio::AgentChat.post_message(soul: @soul, text: text, person: who, ip: request.remote_ip)
71
+ turn = Studio::AgentChat.post_message(soul: @soul, text: text, person: who, ip: request.remote_ip,
72
+ voice: spoken_voice)
60
73
  # A signed-in post that carried the anon token has folded that thread into
61
74
  # the person's: the token is spent. Kept on any failure, for the retry.
62
75
  cookies.delete(ANON_COOKIE) if who.key?(:email) && who.key?(:anon_token)
63
- render json: { turn: turn }, status: :created
76
+ # `voice` is what the hub kept, so the page says who the agent was told spoke.
77
+ render json: { turn: turn.except("voice"), voice: turn["voice"] }.compact, status: :created
64
78
  rescue Studio::AgentChat::Error => e
65
79
  render_hub_error(e)
66
80
  end
@@ -80,6 +94,19 @@ module Studio
80
94
 
81
95
  private
82
96
 
97
+ # The message's `voice`, for a user who may talk; nil otherwise.
98
+ def spoken_voice
99
+ params[:voice] if Studio.voice_chat_allowed?(viewer)
100
+ end
101
+
102
+ def viewer
103
+ respond_to?(:current_user, true) ? current_user : nil
104
+ end
105
+
106
+ def require_chat_allowed
107
+ render_error(NOT_ALLOWED, :forbidden, reason: "not_allowed") unless Studio.agent_chat_allowed?(viewer)
108
+ end
109
+
83
110
  def no_store
84
111
  response.headers["Cache-Control"] = "no-store"
85
112
  end
@@ -128,7 +155,7 @@ module Studio
128
155
  # in docs/GOOGLE_AUTH_SETUP.md) counts. Anyone else chats as an
129
156
  # anonymous visitor.
130
157
  def verified_email
131
- user = respond_to?(:current_user, true) ? current_user : nil
158
+ user = viewer
132
159
  return nil unless user
133
160
 
134
161
  hook = Studio.agent_chat_verified_email
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /marketing-kit: the app's logos, colours and typefaces, for anyone to use.
5
+ # See Studio::MarketingKit and docs/SITE_FOOTER.md.
6
+ #
7
+ # Public by design: a journalist or a partner has no account. It inherits the
8
+ # host's ApplicationController so the page renders in the app's layout, navbar
9
+ # and theme, and it skips each gate a host is known to put there:
10
+ # Studio::ErrorHandling's sign-in gate (every adopter), the hub's admin wall,
11
+ # and turf-monster's profile-completion redirect. A host with a gate of another
12
+ # name skips it in a to_prepare block (docs/SITE_FOOTER.md).
13
+ #
14
+ # It needs no table and no session, so an app with neither can draw it. Where
15
+ # ThemeSetting exists, the colours read it (Studio.marketing_kit_colors).
16
+ class MarketingKitController < ::ApplicationController
17
+ HOST_GATES = %i[require_authentication require_admin_wall require_admin require_login
18
+ authenticate_user! require_profile_completion].freeze
19
+
20
+ HOST_GATES.each { |gate| skip_before_action gate, raise: false }
21
+
22
+ def show; end
23
+ end
24
+ end
@@ -19,6 +19,11 @@ module Studio
19
19
  # CSRF-protected (send the page's token as X-CSRF-Token), JSON, no-store.
20
20
  # The speaker is matched only against the signed-in user's own saved voice.
21
21
  #
22
+ # ALLOWED USERS ONLY (Studio.voice_chat_allowed; by default anyone signed in):
23
+ # anyone else is a JSON 403 {error, reason: "not_allowed"}, before the rate
24
+ # limit counts or anything is read. Hiding the microphone is not the gate; each
25
+ # take is a paid call.
26
+ #
22
27
  # RATE-LIMITED per user (Studio.voice_takes_rate_limit, 20 takes in 10
23
28
  # minutes): over it, a JSON 429 {error, reason: "rate_limited", retry_after}
24
29
  # and a Retry-After header, before anything is read or transcribed.
@@ -31,11 +36,13 @@ module Studio
31
36
  before_action :require_authentication
32
37
  protect_from_forgery with: :exception
33
38
  before_action :no_store
39
+ before_action :require_voice_allowed
34
40
  before_action :limit_takes
35
41
 
36
42
  CANT_HEAR = "I couldn't hear any words in that. Check your microphone and try again."
37
43
  UNAVAILABLE = "I couldn't transcribe that just now. Try again in a moment."
38
44
  TOO_MANY = "That's a lot of recordings in a short time. Wait a moment, then try again."
45
+ NOT_ALLOWED = "Voice isn't available on your account yet. Type instead for now."
39
46
 
40
47
  # The counter's fallback when the app's Rails.cache counts nothing (a
41
48
  # NullStore, as many apps run in development): per process, so a limit
@@ -81,6 +88,10 @@ module Studio
81
88
 
82
89
  private
83
90
 
91
+ def require_voice_allowed
92
+ refuse(NOT_ALLOWED, :forbidden, reason: "not_allowed") unless Studio.voice_chat_allowed?(current_user)
93
+ end
94
+
84
95
  # The per-user limit, a fixed window: the window's number is in the key, so
85
96
  # its end (retry_after) is known exactly. Rails' own rate_limit macro fixes
86
97
  # to:/within: when the class loads and counts nothing on a NullStore; this
@@ -6,14 +6,11 @@
6
6
  # The view is a bare content wrapper that renders inside each host's
7
7
  # application.html.erb, so it inherits that app's navbar + theme automatically.
8
8
  #
9
- # Reached at /admin/style (helper admin_style_path). The legacy /admin/design_system
10
- # route redirects here and keeps its admin_design_system_path helper resolving, so a
11
- # shipped host sidebar link that still points at the old helper keeps working.
9
+ # Reached at /admin/style (helper admin_style_path).
12
10
  class StyleController < ApplicationController
13
11
  before_action :require_admin
14
12
 
15
- # Load the live theme so the Theme section (the landing section, folded in from
16
- # /admin/theme) shows this app's saved colors + the CSRF-protected editor.
13
+ # Load the live theme so the Theme section (the landing section) shows this app's saved colors + the CSRF-protected editor.
17
14
  # Best-effort: the section renders from Studio.theme_config defaults when no
18
15
  # ThemeSetting row exists, so a load hiccup must not take the whole page down.
19
16
  def index
@@ -1,10 +1,11 @@
1
+ # The role colours' save endpoints. The editor lives on the style guide
2
+ # (style/_theme), so GET /admin/theme sends an admin there; the route and its
3
+ # name stay, because hosts link and register them.
1
4
  class ThemeSettingsController < ApplicationController
2
5
  before_action :require_admin
3
6
 
4
7
  def edit
5
- @theme_setting = ThemeSetting.current
6
- @defaults = Studio.theme_config
7
- @preview_css = Studio::ThemeResolver.new(@theme_setting.resolved_colors).to_css
8
+ redirect_to editor_path
8
9
  end
9
10
 
10
11
  def update
@@ -18,10 +19,7 @@ class ThemeSettingsController < ApplicationController
18
19
  rescue StandardError => e
19
20
  return render(json: { error: e.message }, status: :unprocessable_entity) if request.format.json?
20
21
 
21
- @defaults = Studio.theme_config
22
- @preview_css = Studio::ThemeResolver.new(@theme_setting.resolved_colors).to_css
23
- flash.now[:alert] = "Error saving theme: #{e.message}"
24
- render :edit, status: :unprocessable_entity
22
+ redirect_to editor_path, alert: "Error saving theme: #{e.message}"
25
23
  end
26
24
 
27
25
  def regenerate
@@ -31,12 +29,14 @@ class ThemeSettingsController < ApplicationController
31
29
 
32
30
  private
33
31
 
32
+ def editor_path = admin_style_path(anchor: "theme")
33
+
34
34
  # The style guide saves by fetch and asks for JSON: a 204 is its proof of the
35
35
  # write. A form post is redirected back to the editor.
36
36
  def done(notice)
37
37
  return head(:no_content) if request.format.json?
38
38
 
39
- redirect_to admin_theme_path, notice: notice
39
+ redirect_to editor_path, notice: notice
40
40
  end
41
41
 
42
42
  def theme_params
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The agent chat's voice option (docs/AGENT_CHAT.md, "Talking by voice").
5
+ module AgentChatHelper
6
+ # The signed-in user on this page, or nil. A page whose controller has no
7
+ # current_user is a signed-out page.
8
+ def studio_agent_chat_viewer
9
+ respond_to?(:current_user) ? current_user : nil
10
+ end
11
+
12
+ # Whether this viewer gets the microphone: the app turned takes on (no route
13
+ # otherwise) and Studio.voice_chat_allowed lets them talk. Never a signed-out
14
+ # visitor: POST /voice/takes is sign-in only.
15
+ def studio_agent_chat_voice?
16
+ return false unless Studio.voice_transcription && respond_to?(:studio_voice_takes_path)
17
+
18
+ Studio.voice_chat_allowed?(studio_agent_chat_viewer)
19
+ end
20
+
21
+ # The embedder the page runs on a take (Studio::VoiceMatch.browser_config),
22
+ # as JSON, or nil. Only for a user whose saved voice came from the app's own
23
+ # embedder: anything else answers embedder_mismatch or no_profile on the
24
+ # server, so downloading the model would buy nothing.
25
+ def studio_agent_chat_embedder(user)
26
+ return nil unless user.respond_to?(:voice_embedding) && user.respond_to?(:voice_embedding_model)
27
+ return nil if user.voice_embedding.blank?
28
+
29
+ name = Studio.voice_embedder.to_s
30
+ return nil unless user.voice_embedding_model.to_s == Studio::VoiceMatch.model_id(name)
31
+
32
+ Studio::VoiceMatch.browser_config(name).to_json
33
+ rescue ArgumentError, KeyError
34
+ nil
35
+ end
36
+ end
37
+ end
@@ -11,8 +11,8 @@ module Studio
11
11
  # <%= studio_footer_map %> the map alone, e.g. on a contact page
12
12
  #
13
13
  # THE FOOTER IS ON BY DEFAULT: with Studio.site_footer unset, the layout line
14
- # renders the default footer (the site name, the logo, and the legal links the
15
- # host has routes for), and `config.site_footer = false` turns it off. The
14
+ # renders the default footer (the site name, the logo, the legal links the host
15
+ # has routes for, and the Marketing Kit link where the page is drawn), and `config.site_footer = false` turns it off. The
16
16
  # booking helpers render NOTHING without Studio.booking_url, and the map
17
17
  # nothing without an address, so a layout or a shared view can call any of
18
18
  # them before an app has declared anything.
@@ -70,6 +70,19 @@ module Studio
70
70
  zoom: options[:zoom], controls: options.fetch(:controls, true)
71
71
  end
72
72
 
73
+ # The kit /marketing-kit prints (Studio.marketing_kit_for). A callable that
74
+ # raises costs the app's own facts, not the page: in production the error is
75
+ # logged once and the page shows what derives from the theme. In development
76
+ # and test it raises.
77
+ def studio_marketing_kit
78
+ Studio.marketing_kit_for(self)
79
+ rescue StandardError => error
80
+ raise if studio_site_footer_raise?
81
+
82
+ studio_site_footer_report(error)
83
+ Studio.marketing_kit_for(self, declared: nil)
84
+ end
85
+
73
86
  # ---- booking ------------------------------------------------------------
74
87
 
75
88
  def studio_booking? = Studio.booking_url.present?
@@ -18,6 +18,12 @@
18
18
  // until the app answers 201; a refusal shows its reason above them.
19
19
  // FOLLOW the thread stays at its end while the reader is within FOLLOW_PX of
20
20
  // it, and always after their own send (the hub's chat_follow rule).
21
+ // REFRESH the public hook for code outside the chat (studio/agent_chat_voice, a
22
+ // host's own composer): dispatch studio:agent-chat:refresh on the
23
+ // chat's section, optionally with {detail: {turn}}, a turn the app
24
+ // just answered. The turn shows at once, the chat follows it, and it
25
+ // polls now; a poll already in flight is followed by one more, so a
26
+ // refresh is never swallowed.
21
27
  // TYPING the typing line shows while the hub says an answer is pending:
22
28
  // "thinking", then "typing" once a turn's status is "typing".
23
29
  //
@@ -31,6 +37,7 @@ export const IDLE_MS = 30000
31
37
  export const OFFLINE_MS = 15000
32
38
  export const FOLLOW_PX = 20
33
39
  export const LIMIT = 50
40
+ export const REFRESH_EVENT = "studio:agent-chat:refresh"
34
41
  const UNSETTLED = new Set(["pending", "typing"])
35
42
 
36
43
  export function settled(turn) {
@@ -123,7 +130,7 @@ export function mountAgentChat(root, {
123
130
  const soul = root.dataset.agentChatSoul
124
131
  const name = root.dataset.agentChatName || "The agent"
125
132
 
126
- const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, sending: false, timer: null, alive: true, following: true }
133
+ const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true }
127
134
 
128
135
  const onScroll = () => { state.following = isFollowing(log) }
129
136
  log.addEventListener("scroll", onScroll, { passive: true })
@@ -199,10 +206,40 @@ export function mountAgentChat(root, {
199
206
  setOffline(true)
200
207
  } finally {
201
208
  state.inFlight = false
202
- schedule()
209
+ if (state.pollAgain) {
210
+ state.pollAgain = false
211
+ if (state.alive) poll()
212
+ } else {
213
+ schedule()
214
+ }
203
215
  }
204
216
  }
205
217
 
218
+ // The public refresh hook (REFRESH above). Returns poll's promise, or nothing
219
+ // when the poll in flight will be followed by another.
220
+ function refresh({ turn } = {}) {
221
+ if (!state.alive) return undefined
222
+ if (turn) {
223
+ state.turns = mergeTurns(state.turns, [turn])
224
+ state.pending = true
225
+ state.following = true
226
+ state.waitingSince = now()
227
+ setOffline(false)
228
+ render()
229
+ toEnd()
230
+ }
231
+ if (state.inFlight) {
232
+ state.pollAgain = true
233
+ return undefined
234
+ }
235
+ clearTimer(state.timer)
236
+ return poll()
237
+ }
238
+
239
+ function onRefresh(event) {
240
+ refresh(event.detail || {})
241
+ }
242
+
206
243
  async function submit(event) {
207
244
  if (event) event.preventDefault()
208
245
  const text = (input.value || "").trim()
@@ -264,6 +301,7 @@ export function mountAgentChat(root, {
264
301
  form && form.addEventListener("submit", submit)
265
302
  input && input.addEventListener("keydown", onKeydown)
266
303
  retry && retry.addEventListener("click", onRetry)
304
+ root.addEventListener(REFRESH_EVENT, onRefresh)
267
305
  doc && doc.addEventListener && doc.addEventListener("visibilitychange", onVisible)
268
306
  root.dataset.agentChatReady = "true"
269
307
  poll()
@@ -271,6 +309,7 @@ export function mountAgentChat(root, {
271
309
  return {
272
310
  state,
273
311
  poll,
312
+ refresh,
274
313
  submit,
275
314
  destroy() {
276
315
  state.alive = false
@@ -279,6 +318,7 @@ export function mountAgentChat(root, {
279
318
  form && form.removeEventListener("submit", submit)
280
319
  input && input.removeEventListener("keydown", onKeydown)
281
320
  retry && retry.removeEventListener("click", onRetry)
321
+ root.removeEventListener(REFRESH_EVENT, onRefresh)
282
322
  doc && doc.removeEventListener && doc.removeEventListener("visibilitychange", onVisible)
283
323
  delete root.dataset.agentChatReady
284
324
  }