studio-engine 0.121.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 +121 -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 +66 -2
- 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 +8 -3
- 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_profile.js +11 -1
- data/app/javascript/studio/voice_recorder.js +36 -4
- 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 +9 -4
- data/app/views/studio/voice/_styles.html.erb +4 -0
- 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/voice_transcription.rb +100 -34
- data/lib/studio.rb +81 -8
- metadata +11 -4
- data/app/views/studio/admin_models/index.html.erb +0 -31
- data/app/views/theme_settings/edit.html.erb +0 -304
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
application.html.erb, inheriting that app's navbar and theme, so every
|
|
4
4
|
specimen restyles per app and in dark or light automatically.
|
|
5
5
|
|
|
6
|
-
Five ENGINE sections, reached by the sticky
|
|
6
|
+
Five ENGINE sections, reached by the sticky title row below and rendered
|
|
7
7
|
as sibling partials: Theme is the landing section (the color foundation - it
|
|
8
|
-
owns the role tokens/swatches, the
|
|
9
|
-
|
|
8
|
+
owns the role tokens/swatches, the theme editor, the live preview, and the
|
|
9
|
+
logos and type), then Modals (the shared host + engine card blocks), Tricks
|
|
10
10
|
(the button/surface/motion/effect/leveling primitives, framed as a board of
|
|
11
11
|
copy-paste-for-an-agent snippets), Tasks (the shared board primitive), and
|
|
12
12
|
Components (the ViewComponent previews, linking into the gallery at
|
|
@@ -54,36 +54,34 @@
|
|
|
54
54
|
]
|
|
55
55
|
%>
|
|
56
56
|
<div class="max-w-6xl mx-auto px-4 pb-16">
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<div class="flex items-center gap-2 min-w-max">
|
|
82
|
-
<% sections.each do |anchor, label| %>
|
|
83
|
-
<a href="#<%= anchor %>" class="btn btn-neutral btn-sm"><%= label %></a>
|
|
57
|
+
<%# The page's title and its section tabs, one sticky row under the whole pinned
|
|
58
|
+
stack (--pin-stack-bottom; then --nav-bottom and --nav-h for a host on an
|
|
59
|
+
older publisher; then 0). Not --nav-h first: that is the header's HEIGHT,
|
|
60
|
+
and chrome stacked above the header would sit over the row by its own
|
|
61
|
+
height. The tabs scroll sideways on a narrow screen, softened by
|
|
62
|
+
.fade-edge-x; px-8 matches its 2rem fade, so a tab at rest is not faded.
|
|
63
|
+
The row sits at --z-sticky, above --z-raised, so the live hold button's
|
|
64
|
+
portal bubbles scroll under it rather than over it.
|
|
65
|
+
|
|
66
|
+
The style-section-nav controller moves the highlight as the page scrolls
|
|
67
|
+
(studio/style_section_nav). Theme starts active, so the first paint is
|
|
68
|
+
right before the module loads. %>
|
|
69
|
+
<nav class="sticky -mx-4 mt-6 px-4 py-2 bg-surface border-y border-subtle flex items-center gap-2"
|
|
70
|
+
style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)"
|
|
71
|
+
aria-label="Style guide sections"
|
|
72
|
+
data-studio-controller="style-section-nav">
|
|
73
|
+
<h1 class="text-xl font-bold text-heading shrink-0">Style</h1>
|
|
74
|
+
<div class="fade-edge-x overflow-x-auto min-w-0 flex-1" data-style-section-nav-target="scroller">
|
|
75
|
+
<div class="flex items-center gap-2 min-w-max px-8">
|
|
76
|
+
<% sections.each_with_index do |(anchor, label), index| %>
|
|
77
|
+
<a href="#<%= anchor %>"
|
|
78
|
+
class="btn btn-sm <%= index.zero? ? "btn-primary" : "btn-neutral" %>"
|
|
79
|
+
<%= 'aria-current="true"'.html_safe if index.zero? %>
|
|
80
|
+
data-style-section-nav-target="link"><%= label %></a>
|
|
84
81
|
<% end %>
|
|
85
82
|
</div>
|
|
86
83
|
</div>
|
|
84
|
+
<p class="label-upper shrink-0 hidden md:block" title="studio-engine">v<%= Studio::VERSION %></p>
|
|
87
85
|
</nav>
|
|
88
86
|
|
|
89
87
|
<div class="space-y-16 pt-10">
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# One row per signed-in browser session (Studio::UserSession). A small, cached
|
|
2
|
+
# record every app keeps the same way. No foreign key: a host's users table is
|
|
3
|
+
# its own, and a deleted user's sessions may stay as counts.
|
|
4
|
+
class CreateStudioUserSessions < ActiveRecord::Migration[7.2]
|
|
5
|
+
def change
|
|
6
|
+
create_table :studio_user_sessions do |t|
|
|
7
|
+
t.bigint :user_id, null: false
|
|
8
|
+
t.boolean :admin, null: false, default: false
|
|
9
|
+
t.datetime :started_at, null: false
|
|
10
|
+
t.datetime :last_seen_at, null: false
|
|
11
|
+
t.timestamps
|
|
12
|
+
t.index :user_id
|
|
13
|
+
t.index :last_seen_at
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/docs/SITE_FOOTER.md
CHANGED
|
@@ -319,6 +319,39 @@ config.site_footer = ->(view) {
|
|
|
319
319
|
only with a lockup (`studio/site_footer/_lockup_assets`). An engine older
|
|
320
320
|
than the fact ignores the key, so a host can declare it before it upgrades.
|
|
321
321
|
|
|
322
|
+
## The Chat link
|
|
323
|
+
|
|
324
|
+
The standard way to reach an app's agent chat ([`AGENT_CHAT.md`](AGENT_CHAT.md)):
|
|
325
|
+
a plain link at the end of the column headed **Contact** (any case), or a new
|
|
326
|
+
Contact column at the end if the facts have none. An app that sets nothing sees
|
|
327
|
+
no change.
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_path }
|
|
331
|
+
# config.site_footer_chat_label = "Chat" # the default
|
|
332
|
+
# config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
| Setting | Meaning | Unset |
|
|
336
|
+
|---------|---------|-------|
|
|
337
|
+
| `site_footer_chat_path` | A path, or a callable taking the view (route helpers do not exist yet in an initializer). It answers nil to show no link on this page | No link |
|
|
338
|
+
| `site_footer_chat_label` | The link's words | "Chat" |
|
|
339
|
+
| `site_footer_chat_allowed` | A callable taking the viewer: `current_user`, **nil when signed out**, so write `user&.admin?` | `Studio.agent_chat_allowed` (everyone, by default) |
|
|
340
|
+
|
|
341
|
+
- The link looks and behaves as any other column link (`_link.html.erb`). An href
|
|
342
|
+
the footer refuses (`javascript:`) adds nothing, and a Contact column that
|
|
343
|
+
already links the same path keeps its own row and label.
|
|
344
|
+
- A hook that raises hides the link and reports once through
|
|
345
|
+
`Studio::SiteFooter.reporter`, so `user.admin?` on a signed-out viewer shows the
|
|
346
|
+
link to no one and costs no page. Write `user&.admin?` all the same.
|
|
347
|
+
- **Hiding the link is not a gate.** `Studio.agent_chat_allowed` is
|
|
348
|
+
([`AGENT_CHAT.md`](AGENT_CHAT.md), "Who may use it"); set that one, and the link
|
|
349
|
+
follows it.
|
|
350
|
+
- **An admin-only link needs a page that shows the footer to a signed-in viewer.**
|
|
351
|
+
A signed-in viewer sees the footer only on `site_footer_controllers` (see
|
|
352
|
+
*Where it shows*), so list the pages your admins will look at.
|
|
353
|
+
- Never cache the footer across viewers while the link is limited.
|
|
354
|
+
|
|
322
355
|
## Where it shows
|
|
323
356
|
|
|
324
357
|
`studio_site_footer` asks `config.site_footer_visible`, one callable that
|
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
|
@@ -5,6 +5,7 @@ require "net/http"
|
|
|
5
5
|
require "openssl"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "stringio"
|
|
8
|
+
require "timeout"
|
|
8
9
|
require "uri"
|
|
9
10
|
|
|
10
11
|
# Understand: one finished take (the recorder's blob and its mime type, plus the
|
|
@@ -38,17 +39,36 @@ require "uri"
|
|
|
38
39
|
# logger and environment, so the unit suite runs it without an app.
|
|
39
40
|
module Studio
|
|
40
41
|
module VoiceTranscription
|
|
41
|
-
# A transcriber call that failed
|
|
42
|
+
# A transcriber call that failed.
|
|
42
43
|
class Error < StandardError; end
|
|
43
44
|
|
|
44
|
-
# The vendor
|
|
45
|
-
# an auth failure from a model that fell
|
|
45
|
+
# The vendor failed. The status rides along (0 when nothing answered) with a
|
|
46
|
+
# reason code, so a caller can tell an auth failure from a model that fell
|
|
47
|
+
# over. The message names only the model, the status and the reason, never
|
|
48
|
+
# the vendor's own words: it reaches ErrorLog, and a vendor's error text can
|
|
49
|
+
# echo what it was sent.
|
|
46
50
|
class HttpError < Error
|
|
47
|
-
|
|
51
|
+
# Why, as a code: unauthorized (401/403), rate_limited (429),
|
|
52
|
+
# server_error (5xx), rejected (another non-2xx), timeout (the deadline
|
|
53
|
+
# ran out), unreachable (no answer: refused, reset, DNS, TLS).
|
|
54
|
+
REASONS = %w[unauthorized rate_limited server_error rejected timeout unreachable].freeze
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
attr_reader :status, :reason
|
|
57
|
+
|
|
58
|
+
def initialize(message, status:, reason: nil)
|
|
50
59
|
super(message)
|
|
51
60
|
@status = status
|
|
61
|
+
@reason = reason || self.class.reason_for(status)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.reason_for(status)
|
|
65
|
+
case status.to_i
|
|
66
|
+
when 401, 403 then "unauthorized"
|
|
67
|
+
when 429 then "rate_limited"
|
|
68
|
+
when 500..599 then "server_error"
|
|
69
|
+
when 0 then "unreachable"
|
|
70
|
+
else "rejected"
|
|
71
|
+
end
|
|
52
72
|
end
|
|
53
73
|
end
|
|
54
74
|
|
|
@@ -64,7 +84,8 @@ module Studio
|
|
|
64
84
|
|
|
65
85
|
NO_LIVE_CALLS_ENV = "VOICE_NO_LIVE_CALLS"
|
|
66
86
|
TRANSCRIBER_ENV = "STUDIO_VOICE_TRANSCRIBER"
|
|
67
|
-
|
|
87
|
+
# Voice's own key. Deliberately NOT the app's shared OPENAI_API_KEY.
|
|
88
|
+
API_KEY_ENV = "STUDIO_VOICE_OPENAI_API_KEY"
|
|
68
89
|
|
|
69
90
|
# What a page shows when the app has no transcriber key. Says what is wrong
|
|
70
91
|
# without naming the variable to a visitor.
|
|
@@ -123,24 +144,38 @@ module Studio
|
|
|
123
144
|
# The real speech-to-text: OpenAI's POST /v1/audio/transcriptions, one
|
|
124
145
|
# multipart file. Ported from Voicebox's Transcription::OpenAI: the model is
|
|
125
146
|
# told the language (left to guess, a model handed near-silence answers a
|
|
126
|
-
# stock word in any language), and a
|
|
127
|
-
# on the fallback
|
|
147
|
+
# stock word in any language), and a model that fell over (a 5xx, or a 4xx
|
|
148
|
+
# other than auth and 429) is retried once on the fallback from the start of
|
|
149
|
+
# the audio.
|
|
150
|
+
#
|
|
151
|
+
# ONE DEADLINE for the whole take (Studio.voice_transcribe_deadline, 25 s):
|
|
152
|
+
# both attempts share it, every socket timeout is what is left of it, and a
|
|
153
|
+
# hard Timeout backs it, so a vendor that trickles bytes cannot hold a web
|
|
154
|
+
# thread past it. Nothing is retried after a timeout (the deadline is spent),
|
|
155
|
+
# a 429 (the account is throttled; a second model only spends more), an auth
|
|
156
|
+
# failure, or no answer at all; nor with under MIN_RETRY_SECONDS left.
|
|
128
157
|
class OpenAI
|
|
129
158
|
ENDPOINT = "https://api.openai.com/v1/audio/transcriptions"
|
|
159
|
+
# The cap on connecting, inside the deadline.
|
|
130
160
|
OPEN_TIMEOUT = 10
|
|
131
|
-
|
|
132
|
-
|
|
161
|
+
NO_RETRY = %w[unauthorized rate_limited timeout unreachable].freeze
|
|
162
|
+
MIN_RETRY_SECONDS = 2
|
|
133
163
|
|
|
134
|
-
|
|
164
|
+
# The hard backstop's exception; turned into HttpError(reason: "timeout").
|
|
165
|
+
class DeadlineExceeded < Timeout::Error; end
|
|
166
|
+
|
|
167
|
+
attr_reader :model, :fallback_model, :language, :endpoint, :deadline
|
|
135
168
|
|
|
136
169
|
def initialize(api_key: VoiceTranscription.api_key, model: Studio.voice_transcribe_model,
|
|
137
170
|
fallback_model: Studio.voice_transcribe_fallback_model,
|
|
138
|
-
language: Studio.voice_transcribe_language, endpoint: ENDPOINT
|
|
171
|
+
language: Studio.voice_transcribe_language, endpoint: ENDPOINT,
|
|
172
|
+
deadline: Studio.voice_transcribe_deadline)
|
|
139
173
|
@api_key = api_key
|
|
140
174
|
@model = model.to_s
|
|
141
175
|
@fallback_model = fallback_model.to_s.strip.empty? ? nil : fallback_model.to_s
|
|
142
176
|
@language = language.to_s.strip.empty? ? nil : language.to_s
|
|
143
177
|
@endpoint = endpoint
|
|
178
|
+
@deadline = deadline.to_f.positive? ? deadline.to_f : 25.0
|
|
144
179
|
end
|
|
145
180
|
|
|
146
181
|
def configured? = !@api_key.to_s.strip.empty?
|
|
@@ -149,13 +184,24 @@ module Studio
|
|
|
149
184
|
def transcribe(io:, filename:, content_type:)
|
|
150
185
|
raise NotConfigured, "#{API_KEY_ENV} is not set, so this app cannot transcribe voice" unless configured?
|
|
151
186
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
187
|
+
ends_at = now + deadline
|
|
188
|
+
begin
|
|
189
|
+
request_text(model, io, filename, content_type, ends_at)
|
|
190
|
+
rescue HttpError => e
|
|
191
|
+
raise unless retry?(e, ends_at)
|
|
155
192
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
193
|
+
VoiceTranscription.log_warn("#{model} transcription failed (HTTP #{e.status}, #{e.reason}); " \
|
|
194
|
+
"retrying on #{fallback_model}")
|
|
195
|
+
io.rewind
|
|
196
|
+
request_text(fallback_model, io, filename, content_type, ends_at)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def retry?(error, ends_at)
|
|
201
|
+
return false if fallback_model.nil? || fallback_model == model
|
|
202
|
+
return false if NO_RETRY.include?(error.reason)
|
|
203
|
+
|
|
204
|
+
ends_at - now >= MIN_RETRY_SECONDS
|
|
159
205
|
end
|
|
160
206
|
|
|
161
207
|
# The multipart fields, apart from the socket so a test reads them.
|
|
@@ -184,36 +230,52 @@ module Studio
|
|
|
184
230
|
|
|
185
231
|
private
|
|
186
232
|
|
|
187
|
-
def
|
|
188
|
-
|
|
233
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
234
|
+
|
|
235
|
+
def request_text(model_name, io, filename, content_type, ends_at)
|
|
236
|
+
perform(model_name, io, filename, content_type, ends_at).fetch("text", "").to_s.strip
|
|
189
237
|
end
|
|
190
238
|
|
|
191
239
|
# The only place a socket opens, so the only place the trap sits.
|
|
192
|
-
|
|
240
|
+
# uri.hostname, not uri.host: an IPv6 literal's host keeps its brackets
|
|
241
|
+
# ("[::1]"), which neither the loopback check nor the connect reads.
|
|
242
|
+
def perform(model_name, io, filename, content_type, ends_at)
|
|
193
243
|
uri = URI.parse(endpoint)
|
|
194
|
-
VoiceTranscription.guard_live_call!("OpenAI", host: uri.
|
|
244
|
+
VoiceTranscription.guard_live_call!("OpenAI", host: uri.hostname)
|
|
245
|
+
|
|
246
|
+
left = ends_at - now
|
|
247
|
+
raise HttpError.new("OpenAI #{model_name} was not tried: the take's deadline ran out", status: 0, reason: "timeout") if left <= 0
|
|
195
248
|
|
|
196
249
|
request = build_request(model_name, io, filename, content_type)
|
|
197
|
-
response =
|
|
198
|
-
|
|
199
|
-
|
|
250
|
+
response = Timeout.timeout(left, DeadlineExceeded) do
|
|
251
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: [OPEN_TIMEOUT, left].min,
|
|
252
|
+
read_timeout: left, write_timeout: left) do |http|
|
|
253
|
+
http.request(request)
|
|
254
|
+
end
|
|
200
255
|
end
|
|
201
256
|
interpret(response, model_name)
|
|
202
|
-
rescue Timeout::Error
|
|
203
|
-
raise HttpError.new("OpenAI #{model_name}
|
|
257
|
+
rescue Timeout::Error => e
|
|
258
|
+
raise HttpError.new("OpenAI #{model_name} did not answer within the take's #{deadline.round(1)} s deadline (#{e.class})",
|
|
259
|
+
status: 0, reason: "timeout")
|
|
260
|
+
rescue SystemCallError, IOError, OpenSSL::SSL::SSLError, SocketError => e
|
|
261
|
+
raise HttpError.new("OpenAI #{model_name} could not be reached (#{e.class})", status: 0, reason: "unreachable")
|
|
204
262
|
end
|
|
205
263
|
|
|
264
|
+
# A failure carries the status and its reason code, never the vendor's
|
|
265
|
+
# words (HttpError).
|
|
206
266
|
def interpret(response, model_name)
|
|
267
|
+
status = response.code.to_i
|
|
268
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
269
|
+
reason = HttpError.reason_for(status)
|
|
270
|
+
raise HttpError.new("OpenAI #{model_name} answered HTTP #{status} (#{reason})", status: status, reason: reason)
|
|
271
|
+
end
|
|
272
|
+
|
|
207
273
|
parsed = begin
|
|
208
274
|
JSON.parse(response.body.to_s)
|
|
209
275
|
rescue JSON::ParserError
|
|
210
276
|
{}
|
|
211
277
|
end
|
|
212
|
-
parsed
|
|
213
|
-
return parsed if response.is_a?(Net::HTTPSuccess)
|
|
214
|
-
|
|
215
|
-
detail = parsed.dig("error", "message") || response.body.to_s[0, 200]
|
|
216
|
-
raise HttpError.new("OpenAI #{model_name} answered HTTP #{response.code}: #{detail}", status: response.code.to_i)
|
|
278
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
217
279
|
end
|
|
218
280
|
end
|
|
219
281
|
|
|
@@ -269,7 +331,8 @@ module Studio
|
|
|
269
331
|
klass.new
|
|
270
332
|
end
|
|
271
333
|
|
|
272
|
-
# Studio.voice_openai_api_key, else
|
|
334
|
+
# Studio.voice_openai_api_key, else STUDIO_VOICE_OPENAI_API_KEY. Never the
|
|
335
|
+
# app's shared OPENAI_API_KEY: voice spends on its own key and budget.
|
|
273
336
|
def api_key
|
|
274
337
|
key = Studio.voice_openai_api_key.to_s.strip
|
|
275
338
|
key.empty? ? ENV[API_KEY_ENV].to_s.strip : key
|
|
@@ -285,9 +348,12 @@ module Studio
|
|
|
285
348
|
rails_env == "test"
|
|
286
349
|
end
|
|
287
350
|
|
|
351
|
+
# This machine. host is URI#hostname, so IPv6 arrives unbracketed.
|
|
352
|
+
LOOPBACK_HOSTS = %w[127.0.0.1 localhost ::1].freeze
|
|
353
|
+
|
|
288
354
|
def guard_live_call!(vendor, host:)
|
|
289
355
|
return unless live_calls_blocked?
|
|
290
|
-
return if
|
|
356
|
+
return if LOOPBACK_HOSTS.include?(host.to_s)
|
|
291
357
|
|
|
292
358
|
raise LiveCallAttempted, "A live #{vendor} call was attempted with #{NO_LIVE_CALLS_ENV} armed. " \
|
|
293
359
|
"Tests drive the mock transcriber (Studio.voice_transcriber = \"mock\")."
|