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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b205f173a74b9bb8df878abf6b1e991cdc68b4a24d149ee0dbf5ea98d5d49ead
|
|
4
|
+
data.tar.gz: 01b80bee0be70951eb51e4e151639ab65c096972374b12ef5d2941b58411d2c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94aae578a81075f61b21504530ef3998b0b5db37b90876f7038445e3a90938645130833c7ce9c163ec247870843afc99a4e1d24d59deeeff27db1bf6bbcbb428
|
|
7
|
+
data.tar.gz: 13d649f6aa168b162fa6b18b6c098cd97e5c2b385964337f127f09de9f323773803c22d363ee7c9b159801b894e95c9e13d7385fec2ec83133ac4c0191e0af88
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,84 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.123.0 — 2026-11-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Hold to talk in the shared agent chat.** `render "studio/agent_chat/chat", ..., voice: true`
|
|
12
|
+
puts the microphone chip and a hold-to-talk button in the composer's mic slot,
|
|
13
|
+
with the approved privacy line under it (`Studio::AgentChat::VOICE_PRIVACY`;
|
|
14
|
+
`voice_copy: { privacy:, recorder:, chip: }` overrides). Off by default, and
|
|
15
|
+
shown only where `Studio.voice_transcription` is on. Moved from the hub's
|
|
16
|
+
`chat/_voice` and `voice_chat.js` (new: `studio/agent_chat_voice`, the lazy
|
|
17
|
+
`agent-chat-voice` controller). Two fixes on the way: a new hold clears the last
|
|
18
|
+
take's line, and a take recorded during transcription waits its turn. A failed
|
|
19
|
+
send now adds the words after a typed draft instead of losing them.
|
|
20
|
+
[`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md) carries the adoption checklist.
|
|
21
|
+
- **`Studio.voice_chat_allowed`**, a callable taking the signed-in user (default:
|
|
22
|
+
anyone signed in). It decides who sees the mic, who may post to
|
|
23
|
+
`POST /voice/takes` (a JSON `403 {error, reason: "not_allowed"}` otherwise), and
|
|
24
|
+
whether the proxy forwards a message's `voice`. A signed-out visitor is never
|
|
25
|
+
allowed, whatever the hook says.
|
|
26
|
+
- **`studio:voice-recorder:hint`**, on the recorder: `{ kind, text }` for a line
|
|
27
|
+
while it records. A blank `copy.recording[kind]` now blanks that line.
|
|
28
|
+
- **`Studio.agent_chat_allowed`**, the chat's server-side gate: a callable taking
|
|
29
|
+
the user, nil when signed out (default: everyone, so a public chat is unchanged).
|
|
30
|
+
`Studio::AgentChatsController` answers a refused viewer a JSON `403 {error, reason:
|
|
31
|
+
"not_allowed"}` on both calls before any hub call, and the chat partial renders
|
|
32
|
+
nothing for them. The footer's Chat link follows it unless
|
|
33
|
+
`site_footer_chat_allowed` is set, and a user it refuses may not talk by voice.
|
|
34
|
+
- **The proxy carries `voice`.** `POST /agent_chat/messages` and
|
|
35
|
+
`Studio::AgentChat.post_message(voice:)` forward `{check, score, duration_ms}`
|
|
36
|
+
to the hub, and never a name: the hub names the speaker. A malformed `voice` is
|
|
37
|
+
a `422`. When the hub answers what it kept, the proxy answers `{turn, voice}`.
|
|
38
|
+
- **A public refresh hook on the chat.** Dispatch `studio:agent-chat:refresh` on
|
|
39
|
+
the chat's section, optionally with `{detail: {turn}}`: the turn shows at once
|
|
40
|
+
and the chat polls now; a poll in flight is followed by one more.
|
|
41
|
+
- **The footer's standard Chat link.** `Studio.site_footer_chat_path` (a path or a
|
|
42
|
+
callable taking the view), `site_footer_chat_label` (default "Chat") and
|
|
43
|
+
`site_footer_chat_allowed` (a callable taking the viewer, nil when signed out;
|
|
44
|
+
default `Studio.agent_chat_allowed`) add a plain link at the end of the Contact
|
|
45
|
+
column, or a Contact column if the facts have none. No path, no link.
|
|
46
|
+
[`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md), *The Chat link*.
|
|
47
|
+
- **`Studio::UserSession` and `studio_user_sessions`.** One row per signed-in browser
|
|
48
|
+
session: user, admin, started, last seen. A new row after 30 idle minutes; at most one
|
|
49
|
+
write per 5 minutes, and no query between writes. No IP and no user agent. An app with
|
|
50
|
+
the migration records automatically.
|
|
51
|
+
- **A standard Sessions tab on the admin models page** when the table exists.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **The admin models page is one table per tab.** The title and tabs share one sticky
|
|
56
|
+
row and the open tab is highlighted. `/admin/models` shows the first tab; a page's `h1`
|
|
57
|
+
is now always "Models". `studio/admin_models/index` and `PREVIEW_LIMIT` are gone.
|
|
58
|
+
- **`/admin/style` has one sticky header row.** The title and the section tabs
|
|
59
|
+
share it, and the tab highlight follows the section in view
|
|
60
|
+
(`studio/style_section_nav`). The intro paragraph is gone; the version sits at
|
|
61
|
+
the row's right.
|
|
62
|
+
- **`GET /admin/theme` redirects to `/admin/style#theme`.** The route and the
|
|
63
|
+
`admin_theme_path` helper stay, and so do the save endpoints. A form save now
|
|
64
|
+
lands on the style guide. The standalone page's logos, semantic tokens and type
|
|
65
|
+
moved to the Theme section; its button and component specimens were already in
|
|
66
|
+
Tricks.
|
|
67
|
+
|
|
68
|
+
### Removed
|
|
69
|
+
|
|
70
|
+
- **`/admin/design_system` and `admin_design_system_path`.** The route only
|
|
71
|
+
redirected to `/admin/style`. No app links it.
|
|
72
|
+
- **`theme_settings/edit` and the `themeEditor` factory** (`window.themeEditor`).
|
|
73
|
+
`dsThemeEditor` is the one editor.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- **The microphone help's Close never submits a surrounding form.** It is a plain
|
|
78
|
+
button now, not `<form method="dialog">`, which the parser drops inside a form.
|
|
79
|
+
- **A hold-to-talk recorder stops when a held key loses focus**, instead of recording
|
|
80
|
+
to the cap and sending a take on its own, and a right-click or control-click no
|
|
81
|
+
longer starts a take.
|
|
82
|
+
- **The emoji swap no longer clips a large glyph.** Its box is `max(1.25rem, 1.25em)` tall and
|
|
83
|
+
`max(1.5rem, 1.25em)` wide, so a 24px emoji fits.
|
|
84
|
+
|
|
7
85
|
## 0.122.0 — 2026-11-01
|
|
8
86
|
|
|
9
87
|
### Added
|
data/README.md
CHANGED
|
@@ -43,7 +43,7 @@ resolved.
|
|
|
43
43
|
- **Branded loader**: `studio_loader`, `studio_loader_pending` and `studio_loader_overlay` draw the app's own icon in its theme colours for page loads, long waits and pending buttons, in the style `config.loader` names (Breathe by default). See [`docs/LOADER.md`](docs/LOADER.md).
|
|
44
44
|
- **Public user page**: `/u/:username` shows a user's avatar and username (nothing else), and unfurls with their avatar, falling back to the site image. Link any username to it with `link_to_user_profile(user)` or `studio_user_profile_path(user)`. Opt in with `config.draw_public_user_routes = true`. See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
|
|
45
45
|
- **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
|
|
46
|
-
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
46
|
+
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off; `config.site_footer_chat_path` adds a standard Chat link to the Contact column. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
47
47
|
- **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
|
|
48
48
|
- **Voice profile**: an opt-in row on `/profile` ("Set up your voice"): the person reads three phrases with a live level meter, their device turns each take into a speaker embedding (WeSpeaker via transformers.js), and only the averaged vector is saved on their user, never audio. `Studio::VoiceMatch.identify(embedding, among: User.all, model:)` answers who is speaking. Turn it on with `config.voice_profile = true` after installing the migration. See [`docs/VOICE_PROFILE.md`](docs/VOICE_PROFILE.md).
|
|
49
49
|
- **Voice capture**: the microphone chip (names the microphone a page records from, picks another, Chrome help dialog), one level meter, and a record button that refuses a silent take and hands a heard one to the page as a `studio:voice-recorder:take` event with the blob; it uploads nothing. Render `studio/voice/microphone_chip` and `studio/voice/recorder`. See [`docs/VOICE_CAPTURE.md`](docs/VOICE_CAPTURE.md).
|
|
@@ -249,7 +249,7 @@ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys
|
|
|
249
249
|
|
|
250
250
|
**Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
|
|
251
251
|
|
|
252
|
-
Set `Studio.agent_chat_souls = %w[alex]` to add the shared agent chat's proxy (`POST /agent_chat/messages`, `GET /agent_chat/turns`), which forwards to the McRitchie Studio hub with the app's `AGENT_CHAT_TOKEN`; then `render "studio/agent_chat/chat", soul: "alex", name: "Alex", portrait: ...` on any page ([`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md)).
|
|
252
|
+
Set `Studio.agent_chat_souls = %w[alex]` to add the shared agent chat's proxy (`POST /agent_chat/messages`, `GET /agent_chat/turns`), which forwards to the McRitchie Studio hub with the app's `AGENT_CHAT_TOKEN`; then `render "studio/agent_chat/chat", soul: "alex", name: "Alex", portrait: ...` on any page, with `voice: true` for hold to talk in the composer ([`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md)).
|
|
253
253
|
|
|
254
254
|
In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
|
|
255
255
|
|
|
@@ -2,7 +2,6 @@ module Studio
|
|
|
2
2
|
module AdminModels
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
-
PREVIEW_LIMIT = 10
|
|
6
5
|
PER_PAGE = 25
|
|
7
6
|
TEAM_SORTS = {
|
|
8
7
|
"team" => "LOWER(teams.name)",
|
|
@@ -10,27 +9,22 @@ module Studio
|
|
|
10
9
|
"league" => "LOWER(COALESCE(teams.league, ''))"
|
|
11
10
|
}.freeze
|
|
12
11
|
SHARED_TABLE_KEYS = %w[teams arenas].freeze
|
|
12
|
+
SESSIONS_CONFIG = { label: "Sessions", description: "Signed-in sessions, most recent first" }.freeze
|
|
13
13
|
|
|
14
14
|
included do
|
|
15
15
|
before_action :set_model_config, only: :show
|
|
16
16
|
helper Studio::AdminModelsTableHelper
|
|
17
|
-
helper_method :team_sort_url, :team_sort_indicator, :team_sport_emoji,
|
|
17
|
+
helper_method :admin_model_configs, :team_sort_url, :team_sort_indicator, :team_sport_emoji,
|
|
18
18
|
:team_record_json, :admin_models_table_partial
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# The first tab. Each model has its own tab and page (#show); this page is that
|
|
22
|
+
# first one, so /admin/models always shows a table.
|
|
21
23
|
def index
|
|
22
|
-
@
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
label: config.fetch(:label),
|
|
27
|
-
description: config.fetch(:description),
|
|
28
|
-
count: scope.count,
|
|
29
|
-
records: scope.limit(PREVIEW_LIMIT)
|
|
30
|
-
}
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
render "studio/admin_models/index"
|
|
24
|
+
@key = admin_model_configs.keys.first.to_s
|
|
25
|
+
@config = admin_model_configs.fetch(@key)
|
|
26
|
+
@scope = admin_model_scope(@key)
|
|
27
|
+
show
|
|
34
28
|
end
|
|
35
29
|
|
|
36
30
|
def show
|
|
@@ -47,11 +41,23 @@ module Studio
|
|
|
47
41
|
def set_model_config
|
|
48
42
|
@key = params[:key].to_s
|
|
49
43
|
@config = admin_model_configs.fetch(@key) { raise ActiveRecord::RecordNotFound }
|
|
50
|
-
@scope =
|
|
44
|
+
@scope = admin_model_scope(@key)
|
|
51
45
|
end
|
|
52
46
|
|
|
47
|
+
# The host's models, with the engine's standard ones after its Users tab. A host
|
|
48
|
+
# adds a model by adding it to MODELS and answering it in #admin_model_scope_for.
|
|
53
49
|
def admin_model_configs
|
|
54
|
-
self.class::MODELS
|
|
50
|
+
configs = self.class::MODELS.to_a
|
|
51
|
+
return configs.to_h unless Studio::UserSession.available?
|
|
52
|
+
|
|
53
|
+
at = (configs.index { |key, _| key.to_s == "users" } || -1) + 1
|
|
54
|
+
configs.insert(at, ["sessions", SESSIONS_CONFIG]).to_h
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def admin_model_scope(key)
|
|
58
|
+
return Studio::UserSession.includes(:user).recent if key.to_s == "sessions"
|
|
59
|
+
|
|
60
|
+
admin_model_scope_for(key)
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
def admin_model_scope_for(_key)
|
|
@@ -107,6 +113,8 @@ module Studio
|
|
|
107
113
|
end
|
|
108
114
|
|
|
109
115
|
def admin_models_table_partial(key)
|
|
116
|
+
return "studio/admin_models/sessions_table" if key.to_s == "sessions"
|
|
117
|
+
|
|
110
118
|
shared_table_keys = if self.class.const_defined?(:SHARED_TABLE_KEYS, false)
|
|
111
119
|
self.class::SHARED_TABLE_KEYS
|
|
112
120
|
else
|
|
@@ -21,6 +21,7 @@ module Studio
|
|
|
21
21
|
rescue_from "Sluggable::SlugRefused", with: :handle_slug_refused
|
|
22
22
|
|
|
23
23
|
before_action :require_authentication
|
|
24
|
+
after_action :track_user_session
|
|
24
25
|
|
|
25
26
|
helper_method :current_user, :logged_in?, :sso_user_available?, :sso_display_name, :sso_source_app, :sso_hub_logo, :admin?,
|
|
26
27
|
:onchain_session?, :wallet_context, :client_session_payload
|
|
@@ -28,6 +29,18 @@ module Studio
|
|
|
28
29
|
|
|
29
30
|
private
|
|
30
31
|
|
|
32
|
+
# The signed-in person, not whoever an admin is impersonating. A signed-out
|
|
33
|
+
# request stops at the cookie: it runs no query it would not otherwise run. A
|
|
34
|
+
# request must not fail because its bookkeeping did.
|
|
35
|
+
def track_user_session
|
|
36
|
+
return unless session[Studio.session_key]
|
|
37
|
+
|
|
38
|
+
user = respond_to?(:true_user, true) ? true_user : current_user
|
|
39
|
+
Studio::UserSession.track(session, user) if user
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Rails.logger&.warn("[user_session] #{e.class}: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
|
|
31
44
|
def current_user
|
|
32
45
|
return @current_user if defined?(@current_user)
|
|
33
46
|
@current_user = User.find_by(id: session[Studio.session_key])
|
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
158
|
+
user = viewer
|
|
132
159
|
return nil unless user
|
|
133
160
|
|
|
134
161
|
hook = Studio.agent_chat_verified_email
|
|
@@ -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).
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
}
|