studio-engine 0.127.0 → 0.129.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 +161 -0
- data/README.md +9 -3
- data/app/assets/javascripts/studio/page_transition_reveal.js +1 -1
- data/app/assets/tailwind/studio_engine/engine.css +572 -37
- data/app/controllers/concerns/studio/error_handling.rb +17 -0
- data/app/controllers/studio/surveys_controller.rb +16 -3
- data/app/helpers/studio/loader_helper.rb +2 -2
- data/app/helpers/studio_icon_helper.rb +10 -0
- data/app/javascript/studio/agent_chat.js +88 -6
- data/app/javascript/studio/agent_chat_voice.js +59 -5
- data/app/javascript/studio/alpine_stores.js +80 -22
- data/app/javascript/studio/loader.js +130 -5
- data/app/javascript/studio/microphone_chip.js +28 -2
- data/app/javascript/studio/page_transitions.js +28 -19
- data/app/javascript/studio/survey.js +16 -3
- data/app/javascript/studio/theme_editor.js +4 -0
- data/app/javascript/studio/voice_recorder.js +18 -7
- data/app/models/studio/survey_response.rb +24 -17
- data/app/views/components/_geo_signpost.html.erb +1 -0
- data/app/views/components/_link_sidebar.html.erb +40 -12
- data/app/views/components/_sidebar_panel.html.erb +15 -5
- data/app/views/layouts/studio/_head.html.erb +18 -7
- data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
- data/app/views/layouts/studio/component_preview.html.erb +1 -1
- data/app/views/registrations/new.html.erb +4 -3
- data/app/views/sessions/new.html.erb +4 -3
- data/app/views/studio/agent_chat/_chat.html.erb +2 -1
- data/app/views/studio/agent_chat/_turn.html.erb +3 -1
- data/app/views/studio/agent_chat/_voice.html.erb +28 -13
- data/app/views/studio/loader/_overlay.html.erb +4 -4
- data/app/views/style/_components.html.erb +2 -4
- data/app/views/style/_glass.html.erb +83 -0
- data/app/views/style/_host.html.erb +2 -10
- data/app/views/style/_modals.html.erb +38 -231
- data/app/views/style/_scrims.html.erb +2 -9
- data/app/views/style/_site_footer.html.erb +5 -13
- data/app/views/style/_tasks.html.erb +16 -27
- data/app/views/style/_theme.html.erb +56 -78
- data/app/views/style/_tricks.html.erb +13 -11
- data/app/views/style/_type_ladder.html.erb +33 -0
- data/app/views/style/index.html.erb +5 -1
- data/docs/SITE_FOOTER.md +1 -1
- data/lib/studio/agent_chat.rb +4 -2
- data/lib/studio/icons/README.md +12 -0
- data/lib/studio/icons/lucide/LICENSE +43 -0
- data/lib/studio/icons/lucide/activity.svg +15 -0
- data/lib/studio/icons/lucide/book-open.svg +16 -0
- data/lib/studio/icons/lucide/chart-column.svg +18 -0
- data/lib/studio/icons/lucide/diamond.svg +15 -0
- data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
- data/lib/studio/icons/lucide/globe.svg +17 -0
- data/lib/studio/icons/lucide/hammer.svg +17 -0
- data/lib/studio/icons/lucide/inbox.svg +16 -0
- data/lib/studio/icons/lucide/key-round.svg +16 -0
- data/lib/studio/icons/lucide/layout-grid.svg +18 -0
- data/lib/studio/icons/lucide/log-out.svg +17 -0
- data/lib/studio/icons/lucide/message-circle.svg +15 -0
- data/lib/studio/icons/lucide/package.svg +18 -0
- data/lib/studio/icons/lucide/palette.svg +19 -0
- data/lib/studio/icons/lucide/plug.svg +18 -0
- data/lib/studio/icons/lucide/rocket.svg +18 -0
- data/lib/studio/icons/lucide/user.svg +16 -0
- data/lib/studio/icons.rb +52 -0
- data/lib/studio/loader.rb +7 -0
- data/lib/studio/sidebar_sections.rb +52 -4
- data/lib/studio/site_footer.rb +23 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +54 -2
- metadata +24 -1
|
@@ -7,6 +7,8 @@ module Studio
|
|
|
7
7
|
# methods only.
|
|
8
8
|
include Studio::SessionDrift
|
|
9
9
|
|
|
10
|
+
INVALID_TOKEN_MESSAGE = "This page has expired. Reload it, then try again."
|
|
11
|
+
|
|
10
12
|
included do
|
|
11
13
|
# ORDER IS LOAD-BEARING — ActiveSupport::Rescuable resolves handlers with
|
|
12
14
|
# `reverse_each`, so the LAST matching rescue_from registered wins. The
|
|
@@ -19,6 +21,7 @@ module Studio
|
|
|
19
21
|
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
|
20
22
|
# A string, so including this concern does not load the model concern.
|
|
21
23
|
rescue_from "Sluggable::SlugRefused", with: :handle_slug_refused
|
|
24
|
+
rescue_from ActionController::InvalidAuthenticityToken, with: :handle_invalid_authenticity_token
|
|
22
25
|
|
|
23
26
|
before_action :require_authentication
|
|
24
27
|
after_action :track_user_session
|
|
@@ -271,6 +274,20 @@ module Studio
|
|
|
271
274
|
end
|
|
272
275
|
end
|
|
273
276
|
|
|
277
|
+
# Layer 1: a bad or missing CSRF token on a fetch is a 422 the page can read, with
|
|
278
|
+
# no ErrorLog row: anyone can send one, and the catch-all answered it 500.
|
|
279
|
+
#
|
|
280
|
+
# HTML stays with the catch-all on purpose. Its redirect home hands a stale login or
|
|
281
|
+
# magic-link form a fresh token and a way on, where public/422.html is a dead end
|
|
282
|
+
# with no link (Back shows the same stale form). Its row is what the health report
|
|
283
|
+
# sees when a session config breaks every form at once.
|
|
284
|
+
def handle_invalid_authenticity_token(exception)
|
|
285
|
+
respond_to do |format|
|
|
286
|
+
format.json { render json: { error: INVALID_TOKEN_MESSAGE, reason: "invalid_token" }, status: :unprocessable_entity }
|
|
287
|
+
format.any { handle_unexpected_error(exception) }
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
274
291
|
# Layer 1: Catch-all for unexpected errors — log + friendly response.
|
|
275
292
|
# Skips logging if rescue_and_log already captured it.
|
|
276
293
|
def handle_unexpected_error(exception)
|
|
@@ -32,11 +32,15 @@ module Studio
|
|
|
32
32
|
return render(json: { error: "unknown question" }, status: :not_found) unless question
|
|
33
33
|
|
|
34
34
|
response = open_response!
|
|
35
|
-
return
|
|
35
|
+
return render_already_complete unless response
|
|
36
36
|
|
|
37
37
|
ok, error = rescue_survey_write(response) { response.record_answer(question, params[:value]) }
|
|
38
38
|
if ok
|
|
39
39
|
render json: { saved: true, key: question.key, answered: response.answers.keys & @survey.keys }
|
|
40
|
+
elsif response.completed?
|
|
41
|
+
# A submit completed it after this request found it open: the same answer
|
|
42
|
+
# as the branch above, never a validation error for the visitor to read.
|
|
43
|
+
render_already_complete
|
|
40
44
|
else
|
|
41
45
|
render json: { saved: false, error: error }, status: :unprocessable_entity
|
|
42
46
|
end
|
|
@@ -48,6 +52,10 @@ module Studio
|
|
|
48
52
|
|
|
49
53
|
submitted = submitted_answers
|
|
50
54
|
@errors = {}
|
|
55
|
+
completed = false
|
|
56
|
+
# One transaction, so record_answer's row lock holds from the first answer to
|
|
57
|
+
# the completion and a late autosave cannot change an answer between the two.
|
|
58
|
+
# The hook runs after the commit.
|
|
51
59
|
Studio::SurveyResponse.transaction do
|
|
52
60
|
@survey.questions.each do |question|
|
|
53
61
|
next unless submitted.key?(question.key)
|
|
@@ -55,10 +63,11 @@ module Studio
|
|
|
55
63
|
ok, error = response.record_answer(question, submitted[question.key])
|
|
56
64
|
@errors[question.key] = error unless ok
|
|
57
65
|
end
|
|
66
|
+
response.missing_required(@survey).each { |key| @errors[key] ||= "This one is required." }
|
|
67
|
+
completed = @errors.empty? && response.complete!(@survey)
|
|
58
68
|
end
|
|
59
|
-
response.missing_required(@survey).each { |key| @errors[key] ||= "This one is required." }
|
|
60
69
|
|
|
61
|
-
if
|
|
70
|
+
if completed
|
|
62
71
|
run_completed_hook(response)
|
|
63
72
|
redirect_to studio_survey_thanks_path(@survey.slug), status: :see_other
|
|
64
73
|
elsif response.completed?
|
|
@@ -77,6 +86,10 @@ module Studio
|
|
|
77
86
|
|
|
78
87
|
private
|
|
79
88
|
|
|
89
|
+
def render_already_complete
|
|
90
|
+
render json: { error: "already complete" }, status: :conflict
|
|
91
|
+
end
|
|
92
|
+
|
|
80
93
|
def load_survey
|
|
81
94
|
@survey = Studio.survey(params[:slug])
|
|
82
95
|
render :not_found, status: :not_found unless @survey
|
|
@@ -60,8 +60,8 @@ module Studio
|
|
|
60
60
|
# and busy button after `max_wait` ms, so a response that became a download
|
|
61
61
|
# never leaves them up; a Turbo visit is never capped.
|
|
62
62
|
# Any studio_loader option (style:, icon:, art:) passes through.
|
|
63
|
-
def studio_loader_overlay(label: "Loading", delay:
|
|
64
|
-
render "studio/loader/overlay", label: label, delay: delay.to_i, max_wait: max_wait.to_i, size: size, options: options
|
|
63
|
+
def studio_loader_overlay(label: "Loading", delay: Studio::Loader::OVERLAY_DELAY_MS, min_show: Studio::Loader::OVERLAY_MIN_SHOW_MS, max_wait: 15_000, size: 96, **options)
|
|
64
|
+
render "studio/loader/overlay", label: label, delay: delay.to_i, min_show: min_show.to_i, max_wait: max_wait.to_i, size: size, options: options
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def studio_loader_size(size)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# studio_icon("rocket", size: 18): one line icon as inline SVG in currentColor, so it takes
|
|
2
|
+
# its color from the text around it and needs no asset request under a strict CSP. The set
|
|
3
|
+
# is Studio::Icons. An unknown name renders nothing (nil), so a caller can fall back:
|
|
4
|
+
#
|
|
5
|
+
# studio_icon(link[:icon]) || link[:emoji]
|
|
6
|
+
module StudioIconHelper
|
|
7
|
+
def studio_icon(name, size: 18, class: nil)
|
|
8
|
+
Studio::Icons.svg(name, size: size, css_class: binding.local_variable_get(:class))&.html_safe
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -18,12 +18,24 @@
|
|
|
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
|
+
// REVEAL the composer sticks over the window's bottom, so on a short window the
|
|
22
|
+
// thread's end sits under it. On load and after a new turn the page
|
|
23
|
+
// scrolls that end clear of the composer, with the same manners as
|
|
24
|
+
// FOLLOW: not for an empty thread, not for a reader who scrolled the page
|
|
25
|
+
// up (revealFollowing, until their own send), and never during a hold,
|
|
26
|
+
// when it waits for the button to be let go.
|
|
21
27
|
// REFRESH the public hook for code outside the chat (studio/agent_chat_voice, a
|
|
22
28
|
// host's own composer): dispatch studio:agent-chat:refresh on the
|
|
23
29
|
// chat's section, optionally with {detail: {turn}}, a turn the app
|
|
24
30
|
// just answered. The turn shows at once, the chat follows it, and it
|
|
25
31
|
// polls now; a poll already in flight is followed by one more, so a
|
|
26
32
|
// refresh is never swallowed.
|
|
33
|
+
// SENT a typed send the app took dispatches studio:agent-chat:sent on the
|
|
34
|
+
// chat's section, {detail: {turn}}; studio/agent_chat_voice clears its
|
|
35
|
+
// old line on it.
|
|
36
|
+
// HELD a turn the hub holds (status "held": stored, no automatic answer is
|
|
37
|
+
// coming) is settled and says so, and never asks for a resend. A hub
|
|
38
|
+
// that still reports it as "failed" gets the failed line.
|
|
27
39
|
// TYPING the typing line shows while the hub says an answer is pending:
|
|
28
40
|
// "thinking", then "typing" once a turn's status is "typing".
|
|
29
41
|
//
|
|
@@ -37,7 +49,13 @@ export const IDLE_MS = 30000
|
|
|
37
49
|
export const OFFLINE_MS = 15000
|
|
38
50
|
export const FOLLOW_PX = 20
|
|
39
51
|
export const LIMIT = 50
|
|
52
|
+
export const REVEAL_GAP_PX = 12
|
|
53
|
+
export const INVALID_TOKEN = "invalid_token"
|
|
54
|
+
export const EXPIRED_MESSAGE = "This page has expired. Copy your message, then reload the page to send it."
|
|
55
|
+
export const RECORDER_STATE_EVENT = "studio:voice-recorder:state"
|
|
56
|
+
const HOLDING = "[data-voice-recorder-state='recording'],[data-voice-recorder-state='starting']"
|
|
40
57
|
export const REFRESH_EVENT = "studio:agent-chat:refresh"
|
|
58
|
+
export const SENT_EVENT = "studio:agent-chat:sent"
|
|
41
59
|
const UNSETTLED = new Set(["pending", "typing"])
|
|
42
60
|
|
|
43
61
|
export function settled(turn) {
|
|
@@ -95,6 +113,12 @@ export function isFollowing(log, px = FOLLOW_PX) {
|
|
|
95
113
|
return log.scrollHeight - log.clientHeight - log.scrollTop <= px
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
// How many pixels of the thread's end the composer (or the window's bottom) hides;
|
|
117
|
+
// zero or less when it is clear of both. Rects are the elements' own.
|
|
118
|
+
export function coveredPx(endRect, composerRect, windowHeight) {
|
|
119
|
+
return endRect.bottom - Math.min(composerRect.top, windowHeight)
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
// The page's authenticity token: the form's own field, else the csrf meta.
|
|
99
123
|
export function csrfToken(form, doc = globalThis.document) {
|
|
100
124
|
const field = form && form.querySelector("input[name='authenticity_token']")
|
|
@@ -114,7 +138,8 @@ export function mountAgentChat(root, {
|
|
|
114
138
|
doc = globalThis.document,
|
|
115
139
|
now = () => Date.now(),
|
|
116
140
|
setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
117
|
-
clearTimer = (id) => globalThis.clearTimeout(id)
|
|
141
|
+
clearTimer = (id) => globalThis.clearTimeout(id),
|
|
142
|
+
CustomEventClass = globalThis.CustomEvent
|
|
118
143
|
} = {}) {
|
|
119
144
|
const $ = (selector) => root.querySelector(selector)
|
|
120
145
|
const log = $("[data-agent-chat-log]")
|
|
@@ -130,13 +155,57 @@ export function mountAgentChat(root, {
|
|
|
130
155
|
const soul = root.dataset.agentChatSoul
|
|
131
156
|
const name = root.dataset.agentChatName || "The agent"
|
|
132
157
|
|
|
133
|
-
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true }
|
|
158
|
+
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true, revealFollowing: true, revealOwed: false }
|
|
134
159
|
|
|
135
160
|
const onScroll = () => { state.following = isFollowing(log) }
|
|
136
161
|
log.addEventListener("scroll", onScroll, { passive: true })
|
|
137
162
|
|
|
138
163
|
function toEnd() { log.scrollTop = log.scrollHeight }
|
|
139
164
|
|
|
165
|
+
// The last thing in the thread's column above the composer that is showing.
|
|
166
|
+
function threadEnd() {
|
|
167
|
+
return [offlineNote, typing].find((el) => el && el.hidden === false) || log
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function windowHeight() {
|
|
171
|
+
return doc && doc.documentElement ? doc.documentElement.clientHeight : 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function covered() {
|
|
175
|
+
const end = threadEnd()
|
|
176
|
+
if (!form || !end.getBoundingClientRect || !form.getBoundingClientRect) return 0
|
|
177
|
+
return coveredPx(end.getBoundingClientRect(), form.getBoundingClientRect(), windowHeight())
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// scrollIntoView, not window.scrollBy: a host may scroll an inner container. The
|
|
181
|
+
// margin is what the composer takes off the bottom of the window once it sticks:
|
|
182
|
+
// its height and its sticky bottom, not its top now, because a composer lower down
|
|
183
|
+
// the page is not stuck until this scroll has run. Instant, so a host's smooth
|
|
184
|
+
// scrolling cannot leave the page moving when a hold begins.
|
|
185
|
+
function revealEnd() {
|
|
186
|
+
if (root.querySelector(HOLDING)) { state.revealOwed = true; return }
|
|
187
|
+
state.revealOwed = false
|
|
188
|
+
if (!state.turns.length || covered() <= 0) return
|
|
189
|
+
const end = threadEnd()
|
|
190
|
+
const stuckAt = parseFloat(globalThis.getComputedStyle?.(form).bottom) || 0
|
|
191
|
+
end.style.scrollMarginBottom = `${form.getBoundingClientRect().height + stuckAt + REVEAL_GAP_PX}px`
|
|
192
|
+
end.scrollIntoView({ block: "end", behavior: "instant" })
|
|
193
|
+
end.style.scrollMarginBottom = ""
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A scroll of the page or a host's container says whether the reader is still at
|
|
197
|
+
// the thread's end. The log's own scroll, the box's and the picker's say nothing:
|
|
198
|
+
// only something that holds the chat can move it. A reveal passes this too, and
|
|
199
|
+
// ends there.
|
|
200
|
+
const onPageScroll = (event) => {
|
|
201
|
+
if (!event.target || !event.target.contains || !event.target.contains(root)) return
|
|
202
|
+
state.revealFollowing = covered() <= FOLLOW_PX
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const onRecorderState = () => {
|
|
206
|
+
if (state.revealOwed && !root.querySelector(HOLDING) && state.following && state.revealFollowing) revealEnd()
|
|
207
|
+
}
|
|
208
|
+
|
|
140
209
|
function setOffline(offline) {
|
|
141
210
|
state.offline = offline
|
|
142
211
|
if (offlineNote) offlineNote.hidden = !offline
|
|
@@ -171,11 +240,16 @@ export function mountAgentChat(root, {
|
|
|
171
240
|
if (text && text.textContent !== String(turn.text ?? "")) text.textContent = String(turn.text ?? "")
|
|
172
241
|
const failed = node.querySelector("[data-agent-chat-failed]")
|
|
173
242
|
if (failed) failed.hidden = turn.status !== "failed"
|
|
243
|
+
const held = node.querySelector("[data-agent-chat-held]")
|
|
244
|
+
if (held) held.hidden = turn.status !== "held"
|
|
174
245
|
}
|
|
175
246
|
const waiting = state.pending || state.turns.some((turn) => !settled(turn))
|
|
176
247
|
if (typing) typing.hidden = !waiting
|
|
177
248
|
if (phase) phase.dataset.phase = typingPhase(state.turns)
|
|
178
|
-
if (follow)
|
|
249
|
+
if (follow) {
|
|
250
|
+
toEnd()
|
|
251
|
+
if (state.revealFollowing) revealEnd()
|
|
252
|
+
}
|
|
179
253
|
}
|
|
180
254
|
|
|
181
255
|
function schedule() {
|
|
@@ -221,8 +295,9 @@ export function mountAgentChat(root, {
|
|
|
221
295
|
if (!state.alive) return undefined
|
|
222
296
|
if (turn) {
|
|
223
297
|
state.turns = mergeTurns(state.turns, [turn])
|
|
224
|
-
state.pending =
|
|
298
|
+
state.pending = turn.status !== "held"
|
|
225
299
|
state.following = true
|
|
300
|
+
state.revealFollowing = true
|
|
226
301
|
state.waitingSince = now()
|
|
227
302
|
setOffline(false)
|
|
228
303
|
render()
|
|
@@ -260,17 +335,19 @@ export function mountAgentChat(root, {
|
|
|
260
335
|
if (response.status === 201 && data.turn) {
|
|
261
336
|
input.value = ""
|
|
262
337
|
state.turns = mergeTurns(state.turns, [data.turn])
|
|
263
|
-
state.pending =
|
|
338
|
+
state.pending = data.turn.status !== "held"
|
|
264
339
|
state.following = true
|
|
340
|
+
state.revealFollowing = true
|
|
265
341
|
setOffline(false)
|
|
266
342
|
render()
|
|
267
343
|
toEnd()
|
|
268
344
|
if (status) status.textContent = "Sent."
|
|
345
|
+
root.dispatchEvent(new CustomEventClass(SENT_EVENT, { detail: { turn: data.turn } }))
|
|
269
346
|
state.waitingSince = now()
|
|
270
347
|
clearTimer(state.timer)
|
|
271
348
|
state.timer = setTimer(poll, POLL_MS)
|
|
272
349
|
} else {
|
|
273
|
-
showError(data.error || `Couldn't send that to ${name}. Try again.`)
|
|
350
|
+
showError(data.reason === INVALID_TOKEN ? EXPIRED_MESSAGE : data.error || `Couldn't send that to ${name}. Try again.`)
|
|
274
351
|
if (status) status.textContent = ""
|
|
275
352
|
}
|
|
276
353
|
} catch (_error) {
|
|
@@ -302,7 +379,10 @@ export function mountAgentChat(root, {
|
|
|
302
379
|
input && input.addEventListener("keydown", onKeydown)
|
|
303
380
|
retry && retry.addEventListener("click", onRetry)
|
|
304
381
|
root.addEventListener(REFRESH_EVENT, onRefresh)
|
|
382
|
+
root.addEventListener(RECORDER_STATE_EVENT, onRecorderState)
|
|
305
383
|
doc && doc.addEventListener && doc.addEventListener("visibilitychange", onVisible)
|
|
384
|
+
// Scroll events do not bubble; capture hears the page's and any container's.
|
|
385
|
+
doc && doc.addEventListener && doc.addEventListener("scroll", onPageScroll, { capture: true, passive: true })
|
|
306
386
|
root.dataset.agentChatReady = "true"
|
|
307
387
|
poll()
|
|
308
388
|
|
|
@@ -319,7 +399,9 @@ export function mountAgentChat(root, {
|
|
|
319
399
|
input && input.removeEventListener("keydown", onKeydown)
|
|
320
400
|
retry && retry.removeEventListener("click", onRetry)
|
|
321
401
|
root.removeEventListener(REFRESH_EVENT, onRefresh)
|
|
402
|
+
root.removeEventListener(RECORDER_STATE_EVENT, onRecorderState)
|
|
322
403
|
doc && doc.removeEventListener && doc.removeEventListener("visibilitychange", onVisible)
|
|
404
|
+
doc && doc.removeEventListener && doc.removeEventListener("scroll", onPageScroll, { capture: true })
|
|
323
405
|
delete root.dataset.agentChatReady
|
|
324
406
|
}
|
|
325
407
|
}
|
|
@@ -18,8 +18,14 @@
|
|
|
18
18
|
// says who it kept.
|
|
19
19
|
//
|
|
20
20
|
// Every refusal is a line in [data-agent-chat-voice-status] (set as text) and a
|
|
21
|
-
// state on data-agent-chat-voice-state: listening, transcribing, queued,
|
|
22
|
-
// sent, silent, no_speech, not_configured, rate_limited, refused, failed.
|
|
21
|
+
// state on data-agent-chat-voice-state: listening, tapped, transcribing, queued,
|
|
22
|
+
// sending, sent, silent, no_speech, not_configured, rate_limited, refused, failed.
|
|
23
|
+
//
|
|
24
|
+
// THE HELD BUTTON DOES NOT MOVE. The line sits above the button and the recorder's
|
|
25
|
+
// own line is hidden here, so this one line says everything. _voice reserves its
|
|
26
|
+
// quiet-hint height, and holdLine fixes its height for the length of a hold: an old
|
|
27
|
+
// line clearing under the finger cannot pull the button down, and a take landing
|
|
28
|
+
// during a hold cannot push it up.
|
|
23
29
|
//
|
|
24
30
|
// NO WORDS ARE LOST. A transcript that could not be sent goes in the chat's box,
|
|
25
31
|
// after anything already typed there. A take that lands while another is still
|
|
@@ -33,6 +39,7 @@ export const EMBED_WAIT_MS = 8000
|
|
|
33
39
|
|
|
34
40
|
export const MESSAGES = {
|
|
35
41
|
listening: "Listening...",
|
|
42
|
+
tapped: "Hold the button while you talk, then let go.",
|
|
36
43
|
transcribing: "Turning that into words...",
|
|
37
44
|
queued: "Got that one too. It's next.",
|
|
38
45
|
sending: (name) => `Sending to ${name}...`,
|
|
@@ -43,6 +50,8 @@ export const MESSAGES = {
|
|
|
43
50
|
notConfigured: "Voice isn't set up on this app yet, so I can't hear recordings. Type instead for now.",
|
|
44
51
|
rateLimited: "That's a lot of voice messages in a row. Wait a moment, then hold to talk again.",
|
|
45
52
|
signedOut: "Sign in again to talk.",
|
|
53
|
+
expired: "This page has expired. Reload it, then hold to talk again.",
|
|
54
|
+
expiredSend: "This page has expired. Copy your words from the box, then reload the page to send them.",
|
|
46
55
|
unavailable: "I couldn't transcribe that just now. Try again in a moment.",
|
|
47
56
|
failed: "That recording didn't go through. Try again.",
|
|
48
57
|
offline: "Can't reach the server right now. Try again in a moment.",
|
|
@@ -58,11 +67,19 @@ export function retryHint(seconds) {
|
|
|
58
67
|
return ` Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
// A refused CSRF token: the engine's own answer (Studio::ErrorHandling), or Rails'
|
|
71
|
+
// {status: 422, error: "Unprocessable Content"} from a host that re-raises it. Retrying
|
|
72
|
+
// on this page can never pass, and Rails' words mean nothing to a person.
|
|
73
|
+
export function tokenRefused(status, body = {}) {
|
|
74
|
+
return status === 422 && !!body && (body.reason === "invalid_token" || body.status === 422)
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
// What POST /voice/takes's refusal means on the page: {state, message}.
|
|
62
78
|
// `body` is the parsed JSON (or {}); `retryAfter` the header, if any.
|
|
63
79
|
export function refusalFor(status, body = {}, retryAfter = null) {
|
|
64
80
|
const said = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : null
|
|
65
81
|
if (status === 401) return { state: "refused", message: MESSAGES.signedOut }
|
|
82
|
+
if (tokenRefused(status, body)) return { state: "refused", message: MESSAGES.expired }
|
|
66
83
|
if (status === 429) {
|
|
67
84
|
return { state: "rate_limited", message: MESSAGES.rateLimited + retryHint(retryAfter ?? (body && body.retry_after)) }
|
|
68
85
|
}
|
|
@@ -157,6 +174,19 @@ export function mountAgentChatVoice(root, {
|
|
|
157
174
|
if (status) status.textContent = message || ""
|
|
158
175
|
}
|
|
159
176
|
|
|
177
|
+
// Takes the line's height before it changes, and hands it back with no argument.
|
|
178
|
+
function holdLine(holding) {
|
|
179
|
+
if (!status || !status.style) return
|
|
180
|
+
status.style.height = holding && status.offsetHeight ? `${status.offsetHeight}px` : ""
|
|
181
|
+
status.style.overflow = holding && status.offsetHeight ? "hidden" : ""
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// What the recorder just said on its own line, which _voice hides here.
|
|
185
|
+
function recorderLine(fallback) {
|
|
186
|
+
const line = root.querySelector("[data-voice-recorder-status]")
|
|
187
|
+
return (line && line.textContent) || fallback
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
function embedder() {
|
|
161
191
|
const config = parseEmbedder(root.getAttribute("data-agent-chat-voice-embedder"))
|
|
162
192
|
if (!config) return Promise.resolve(null)
|
|
@@ -215,6 +245,10 @@ export function mountAgentChatVoice(root, {
|
|
|
215
245
|
return
|
|
216
246
|
}
|
|
217
247
|
keepWords(answer.text)
|
|
248
|
+
if (tokenRefused(response.status, data)) {
|
|
249
|
+
say("refused", MESSAGES.expiredSend)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
218
252
|
const reason = typeof data.error === "string" && data.error ? `${data.error} ` : ""
|
|
219
253
|
say(response.status === 429 ? "rate_limited" : "failed", `${reason}${MESSAGES.notSent(name)}`)
|
|
220
254
|
}
|
|
@@ -235,7 +269,9 @@ export function mountAgentChatVoice(root, {
|
|
|
235
269
|
if (!response.ok || typeof body.text !== "string" || !body.text.trim()) {
|
|
236
270
|
const retryAfter = response.headers && response.headers.get ? response.headers.get("Retry-After") : null
|
|
237
271
|
const { state, message } = response.ok ? { state: "no_speech", message: MESSAGES.noSpeech } : refusalFor(response.status, body, retryAfter)
|
|
238
|
-
|
|
272
|
+
// A reload clears the box, so words already in it get the copy-first line.
|
|
273
|
+
const inBox = form && form.querySelector("[data-agent-chat-input]")?.value.trim()
|
|
274
|
+
say(state, inBox && tokenRefused(response.status, body) ? MESSAGES.expiredSend : message)
|
|
239
275
|
return
|
|
240
276
|
}
|
|
241
277
|
await send(body)
|
|
@@ -278,15 +314,31 @@ export function mountAgentChatVoice(root, {
|
|
|
278
314
|
function onState(event) {
|
|
279
315
|
const next = event.detail && event.detail.state
|
|
280
316
|
if (next === "starting") {
|
|
317
|
+
holdLine(true)
|
|
281
318
|
say("idle", "")
|
|
282
|
-
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (next === "recording") {
|
|
283
322
|
say("listening", MESSAGES.listening)
|
|
284
323
|
embedder().then((loaded) => loaded && loaded.warm && loaded.warm()).catch(() => {})
|
|
285
|
-
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
holdLine(false)
|
|
327
|
+
if (next === "tapped") say("tapped", recorderLine(MESSAGES.tapped))
|
|
328
|
+
else if (next === "failed") say("failed", recorderLine(MESSAGES.failed))
|
|
329
|
+
else if (next === "denied") say("refused", recorderLine(MESSAGES.failed))
|
|
330
|
+
else if (root.dataset.agentChatVoiceState === "listening") {
|
|
286
331
|
say(working ? "transcribing" : "idle", working ? MESSAGES.transcribing : "")
|
|
287
332
|
}
|
|
288
333
|
}
|
|
289
334
|
|
|
335
|
+
// A later typed send (studio/agent_chat): "Sent. Spoken by ..." is about a take
|
|
336
|
+
// the thread has moved past. A take still in hand, or a hold, keeps its own line.
|
|
337
|
+
function onTypedSend() {
|
|
338
|
+
if (working || root.dataset.agentChatVoiceState === "listening") return
|
|
339
|
+
say("idle", "")
|
|
340
|
+
}
|
|
341
|
+
|
|
290
342
|
// "I can't hear anything yet" on the one line: the recorder's own line is hidden
|
|
291
343
|
// in the composer while it records, so a held button never moves for it.
|
|
292
344
|
function onHint(event) {
|
|
@@ -304,6 +356,7 @@ export function mountAgentChatVoice(root, {
|
|
|
304
356
|
root.addEventListener("studio:voice-recorder:silent", onSilent)
|
|
305
357
|
root.addEventListener("studio:voice-recorder:state", onState)
|
|
306
358
|
root.addEventListener("studio:voice-recorder:hint", onHint)
|
|
359
|
+
if (chat && chat.addEventListener) chat.addEventListener("studio:agent-chat:sent", onTypedSend)
|
|
307
360
|
|
|
308
361
|
return {
|
|
309
362
|
takeArrived,
|
|
@@ -313,6 +366,7 @@ export function mountAgentChatVoice(root, {
|
|
|
313
366
|
root.removeEventListener("studio:voice-recorder:silent", onSilent)
|
|
314
367
|
root.removeEventListener("studio:voice-recorder:state", onState)
|
|
315
368
|
root.removeEventListener("studio:voice-recorder:hint", onHint)
|
|
369
|
+
if (chat && chat.removeEventListener) chat.removeEventListener("studio:agent-chat:sent", onTypedSend)
|
|
316
370
|
}
|
|
317
371
|
}
|
|
318
372
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// studio/alpine_stores: the Alpine stores every host body binds to,
|
|
2
|
-
// $store.theme and $store.devMode, and the theme
|
|
2
|
+
// $store.theme and $store.devMode, and the theme setter behind them.
|
|
3
3
|
//
|
|
4
4
|
// WHY THIS IS ITS OWN ENTRY POINT. Every host's <body> says
|
|
5
5
|
// :class="{ 'dev-mode': $store.devMode }", and the engine's theme toggle reads
|
|
@@ -27,36 +27,92 @@
|
|
|
27
27
|
// inline, nonced script in the head: it must run before the stylesheet paints,
|
|
28
28
|
// which no deferred module can.
|
|
29
29
|
|
|
30
|
-
// The
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// The theme the root's class shows. The store starts here, not from storage: the pre-paint script
|
|
31
|
+
// reads storage by its own rule, and it decides what the reader already sees.
|
|
32
|
+
function classTheme(root) {
|
|
33
|
+
return root.classList.contains('dark') ? 'dark' : 'light'
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
root.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
36
|
+
// A visit owns the view transition. A second one would skip Turbo's and cut its motion short, so
|
|
37
|
+
// a toggle during a visit flips at once. data-turbo-visit-direction spans a visit; data-page-transition
|
|
38
|
+
// also spans a full load's arrival (studio/page_transitions).
|
|
39
|
+
function visiting(root) {
|
|
40
|
+
return root.dataset.turboVisitDirection !== undefined || root.dataset.pageTransition !== undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function canCrossfade(env) {
|
|
44
|
+
if (!env.doc || typeof env.doc.startViewTransition !== 'function') return false
|
|
45
|
+
if (env.win.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
|
|
46
|
+
return !visiting(env.root)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The theme store. set(value) is the one setter: the root class, the store value and localStorage
|
|
50
|
+
// change together, in one step, so they agree at every moment. Under a crossfade that step runs
|
|
51
|
+
// in the transition's callback, after the old picture: for up to a frame after set() all three
|
|
52
|
+
// still read the old theme. The pre-paint script is the one writer that cannot use this: it runs
|
|
53
|
+
// before any module does.
|
|
54
|
+
//
|
|
55
|
+
// data-theme-flip is on the root from before the old picture until the crossfade ends. engine.css
|
|
56
|
+
// reads it: the page is one picture, no element runs its own transition, and the page-transition
|
|
57
|
+
// motions lose. Without a crossfade it stays until the styles of the new theme have been computed.
|
|
58
|
+
export function themeStore(env) {
|
|
59
|
+
var root = env.root
|
|
60
|
+
var flips = 0
|
|
61
|
+
var wanted = null // the theme asked for, while its step has not run
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
value: classTheme(root),
|
|
65
|
+
get isDark() { return this.value === 'dark' },
|
|
66
|
+
set: function (value) {
|
|
67
|
+
if (value !== 'dark' && value !== 'light') return
|
|
68
|
+
if (value === wanted) return
|
|
69
|
+
var self = this
|
|
70
|
+
var commit = function () {
|
|
71
|
+
root.classList.toggle('dark', value === 'dark')
|
|
72
|
+
self.value = value
|
|
73
|
+
// A full or blocked storage must not throw past the instant path's settle and strand data-theme-flip.
|
|
74
|
+
try { env.storage.setItem('theme', value) } catch (_) { /* the page still flips; the choice is not kept */ }
|
|
75
|
+
}
|
|
76
|
+
// Nothing to show, but the three may have drifted: a host can write the class directly.
|
|
77
|
+
if (wanted === null && value === classTheme(root)) return commit()
|
|
78
|
+
|
|
79
|
+
var mine = ++flips
|
|
80
|
+
// Only the newest flip acts, so a late callback cannot undo a later choice.
|
|
81
|
+
var apply = function () {
|
|
82
|
+
if (mine !== flips) return
|
|
83
|
+
wanted = null
|
|
84
|
+
commit()
|
|
85
|
+
}
|
|
86
|
+
var settle = function () {
|
|
87
|
+
if (mine === flips) delete root.dataset.themeFlip
|
|
88
|
+
}
|
|
89
|
+
wanted = value
|
|
90
|
+
root.dataset.themeFlip = ''
|
|
91
|
+
if (!canCrossfade(env)) {
|
|
92
|
+
apply()
|
|
93
|
+
if (env.win) env.win.getComputedStyle(root).color // no transition starts from these styles
|
|
94
|
+
return env.later(settle, 0)
|
|
95
|
+
}
|
|
96
|
+
var transition = env.doc.startViewTransition(apply)
|
|
97
|
+
// A skipped transition rejects ready; a throw in apply rejects the other two.
|
|
98
|
+
transition.finished.then(settle, settle)
|
|
99
|
+
transition.ready.catch(function () {})
|
|
100
|
+
transition.updateCallbackDone.catch(function () {})
|
|
101
|
+
},
|
|
102
|
+
toggle: function () {
|
|
103
|
+
this.set((wanted || classTheme(root)) === 'dark' ? 'light' : 'dark')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
44
106
|
}
|
|
45
107
|
|
|
46
108
|
// Registers each store Alpine does not already have. env carries what the
|
|
47
|
-
// stores read and write: { storage, root, later }.
|
|
109
|
+
// stores read and write: { storage, root, later, doc, win }.
|
|
48
110
|
export function registerStudioStores(Alpine, env) {
|
|
49
111
|
if (Alpine.store('devMode') === undefined) {
|
|
50
112
|
Alpine.store('devMode', env.storage.getItem('devMode') === 'true')
|
|
51
113
|
}
|
|
52
114
|
if (Alpine.store('theme') === undefined) {
|
|
53
|
-
Alpine.store('theme',
|
|
54
|
-
value: storedTheme(env.storage),
|
|
55
|
-
get isDark() { return this.value === 'dark' },
|
|
56
|
-
toggle: function () {
|
|
57
|
-
this.value = toggleTheme(env.root, env.storage, env.later)
|
|
58
|
-
}
|
|
59
|
-
})
|
|
115
|
+
Alpine.store('theme', themeStore(env))
|
|
60
116
|
}
|
|
61
117
|
}
|
|
62
118
|
|
|
@@ -72,7 +128,9 @@ export function installStudioStores(doc, win) {
|
|
|
72
128
|
registerStudioStores(win.Alpine, {
|
|
73
129
|
storage: win.localStorage,
|
|
74
130
|
root: doc.documentElement,
|
|
75
|
-
later: function (fn, ms) { win.setTimeout(fn, ms) }
|
|
131
|
+
later: function (fn, ms) { win.setTimeout(fn, ms) },
|
|
132
|
+
doc: doc,
|
|
133
|
+
win: win
|
|
76
134
|
})
|
|
77
135
|
})
|
|
78
136
|
}
|