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
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// studio/agent_chat_voice: hold to talk in the shared agent chat's composer
|
|
2
|
+
// (studio/agent_chat/_voice; docs/AGENT_CHAT.md, "Talking by voice").
|
|
3
|
+
//
|
|
4
|
+
// THE FLOW, for one take the engine recorder hands over
|
|
5
|
+
// (studio:voice-recorder:take {blob, mimeType, durationMs}):
|
|
6
|
+
//
|
|
7
|
+
// 1. embed it ON THIS DEVICE when the page names an embedder (the person has
|
|
8
|
+
// a saved voice; data-agent-chat-voice-embedder is the /profile voice row's
|
|
9
|
+
// own config), with studio/voice_profile's embedderFor. Any failure, or a
|
|
10
|
+
// model slower than EMBED_WAIT_MS, sends the take without a vector: the
|
|
11
|
+
// server then answers "Someone new" (check no_embedding), never an error.
|
|
12
|
+
// 2. POST it to /voice/takes (multipart, X-CSRF-Token), which answers
|
|
13
|
+
// {text, speaker, score, identified, check, duration_ms} and keeps nothing.
|
|
14
|
+
// 3. send `text` to the chat's own form action with `voice` {check, score,
|
|
15
|
+
// duration_ms} and hand the turn to the chat (its public refresh hook,
|
|
16
|
+
// studio:agent-chat:refresh), so the turn and the typing line appear. The
|
|
17
|
+
// browser never sends a name: the server names the speaker, and the line
|
|
18
|
+
// says who it kept.
|
|
19
|
+
//
|
|
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, sending,
|
|
22
|
+
// sent, silent, no_speech, not_configured, rate_limited, refused, failed.
|
|
23
|
+
//
|
|
24
|
+
// NO WORDS ARE LOST. A transcript that could not be sent goes in the chat's box,
|
|
25
|
+
// after anything already typed there. A take that lands while another is still
|
|
26
|
+
// being turned into words waits its turn, in order, and the line says so.
|
|
27
|
+
//
|
|
28
|
+
// It imports nothing at load (studio/voice_profile, and the model behind it, only
|
|
29
|
+
// when a person with a saved voice talks), so test/javascript/agent_chat_voice.test.mjs
|
|
30
|
+
// loads it as a data: module.
|
|
31
|
+
|
|
32
|
+
export const EMBED_WAIT_MS = 8000
|
|
33
|
+
|
|
34
|
+
export const MESSAGES = {
|
|
35
|
+
listening: "Listening...",
|
|
36
|
+
transcribing: "Turning that into words...",
|
|
37
|
+
queued: "Got that one too. It's next.",
|
|
38
|
+
sending: (name) => `Sending to ${name}...`,
|
|
39
|
+
sent: (speaker) => `Sent. Spoken by ${speaker}.`,
|
|
40
|
+
sentPlain: "Sent.",
|
|
41
|
+
silent: (label) => `I couldn't hear anything${label ? ` from ${label}` : ""}. Check your microphone and hold to talk again.`,
|
|
42
|
+
noSpeech: "I couldn't hear any words in that. Check your microphone and try again.",
|
|
43
|
+
notConfigured: "Voice isn't set up on this app yet, so I can't hear recordings. Type instead for now.",
|
|
44
|
+
rateLimited: "That's a lot of voice messages in a row. Wait a moment, then hold to talk again.",
|
|
45
|
+
signedOut: "Sign in again to talk.",
|
|
46
|
+
unavailable: "I couldn't transcribe that just now. Try again in a moment.",
|
|
47
|
+
failed: "That recording didn't go through. Try again.",
|
|
48
|
+
offline: "Can't reach the server right now. Try again in a moment.",
|
|
49
|
+
notSent: (name) => `Couldn't send that to ${name}. Your words are in the box; press send to try again.`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// "Try again in 40 seconds." from a Retry-After header or a retry_after field.
|
|
53
|
+
export function retryHint(seconds) {
|
|
54
|
+
const n = Math.round(Number(seconds))
|
|
55
|
+
if (!Number.isFinite(n) || n <= 0) return ""
|
|
56
|
+
if (n < 60) return ` Try again in ${n} second${n === 1 ? "" : "s"}.`
|
|
57
|
+
const minutes = Math.ceil(n / 60)
|
|
58
|
+
return ` Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// What POST /voice/takes's refusal means on the page: {state, message}.
|
|
62
|
+
// `body` is the parsed JSON (or {}); `retryAfter` the header, if any.
|
|
63
|
+
export function refusalFor(status, body = {}, retryAfter = null) {
|
|
64
|
+
const said = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : null
|
|
65
|
+
if (status === 401) return { state: "refused", message: MESSAGES.signedOut }
|
|
66
|
+
if (status === 429) {
|
|
67
|
+
return { state: "rate_limited", message: MESSAGES.rateLimited + retryHint(retryAfter ?? (body && body.retry_after)) }
|
|
68
|
+
}
|
|
69
|
+
if (status === 422 && body && body.reason === "no_speech") return { state: "no_speech", message: said || MESSAGES.noSpeech }
|
|
70
|
+
if (status === 503 && body && body.reason === "not_configured") return { state: "not_configured", message: said || MESSAGES.notConfigured }
|
|
71
|
+
if (status === 503) return { state: "failed", message: said || MESSAGES.unavailable }
|
|
72
|
+
if ([403, 413, 415, 422].includes(status)) return { state: "refused", message: said || MESSAGES.failed }
|
|
73
|
+
return { state: "failed", message: MESSAGES.failed }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The message's `voice`, from a 200 answer: what the take measured, never a name.
|
|
77
|
+
export function voiceMetadata(answer = {}) {
|
|
78
|
+
const score = typeof answer.score === "number" && Number.isFinite(answer.score) ? answer.score : null
|
|
79
|
+
const duration = Number.isInteger(answer.duration_ms) ? answer.duration_ms : null
|
|
80
|
+
return { check: String(answer.check || "no_embedding"), score, duration_ms: duration }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The multipart body POST /voice/takes reads (docs/VOICE_TRANSCRIPTION.md).
|
|
84
|
+
export function takeForm(detail, { embedding = null, model = null } = {}, FormDataClass = globalThis.FormData) {
|
|
85
|
+
const form = new FormDataClass()
|
|
86
|
+
form.append("voice[audio]", detail.blob, "take")
|
|
87
|
+
if (detail.mimeType) form.append("voice[mime_type]", detail.mimeType)
|
|
88
|
+
form.append("voice[duration_ms]", String(Math.max(1, Math.round(Number(detail.durationMs) || 0))))
|
|
89
|
+
if (Array.isArray(embedding) && embedding.length && model) {
|
|
90
|
+
form.append("voice[embedding]", JSON.stringify(embedding))
|
|
91
|
+
form.append("voice[model]", model)
|
|
92
|
+
}
|
|
93
|
+
return form
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseEmbedder(text) {
|
|
97
|
+
if (!text) return null
|
|
98
|
+
try {
|
|
99
|
+
const config = JSON.parse(text)
|
|
100
|
+
return config && config.name && config.id ? config : null
|
|
101
|
+
} catch (_) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The words in the box, after anything already there: a person's draft is theirs.
|
|
107
|
+
export function wordsInBox(current, text) {
|
|
108
|
+
return current && current.trim() ? `${current.trimEnd()}\n${text}` : text
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The page's token (csrf_meta_tags), else the chat form's. In that order: the
|
|
112
|
+
// meta tag's is good for any action, and the form's is per-form under
|
|
113
|
+
// load_defaults 8.1, bound to POST /agent_chat/messages, so POST /voice/takes
|
|
114
|
+
// refuses it.
|
|
115
|
+
export function csrfToken(form, doc = globalThis.document) {
|
|
116
|
+
const meta = doc && doc.querySelector("meta[name='csrf-token']")
|
|
117
|
+
const content = meta && meta.getAttribute("content")
|
|
118
|
+
if (content) return content
|
|
119
|
+
const field = form && form.querySelector("input[name='authenticity_token']")
|
|
120
|
+
return field && field.value ? field.value : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Resolves to `promise`'s value, or `fallback` once `ms` have passed.
|
|
124
|
+
export function within(promise, ms, fallback = null, setTimer = (fn, t) => globalThis.setTimeout(fn, t)) {
|
|
125
|
+
return Promise.race([promise, new Promise((resolve) => setTimer(() => resolve(fallback), ms))])
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readJson(response) {
|
|
129
|
+
try { return await response.json() } catch (_) { return {} }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Mounts one voice section, which sits inside the chat's form. Returns
|
|
133
|
+
// { takeArrived, destroy } for tests.
|
|
134
|
+
export function mountAgentChatVoice(root, {
|
|
135
|
+
fetch: fetcher = (...args) => globalThis.fetch(...args),
|
|
136
|
+
doc = globalThis.document,
|
|
137
|
+
loadEmbedder = async (config) => {
|
|
138
|
+
const { embedderFor } = await import("studio/voice_profile")
|
|
139
|
+
return embedderFor(config)
|
|
140
|
+
},
|
|
141
|
+
setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
142
|
+
FormDataClass = globalThis.FormData,
|
|
143
|
+
CustomEventClass = globalThis.CustomEvent
|
|
144
|
+
} = {}) {
|
|
145
|
+
const status = root.querySelector("[data-agent-chat-voice-status]")
|
|
146
|
+
const form = root.closest("[data-agent-chat-form]")
|
|
147
|
+
const chat = root.closest("[data-agent-chat-soul]")
|
|
148
|
+
const name = root.dataset.agentChatVoiceName || "the agent"
|
|
149
|
+
const soul = root.dataset.agentChatVoiceSoul
|
|
150
|
+
const embedders = new Map()
|
|
151
|
+
const waiting = []
|
|
152
|
+
let working = false
|
|
153
|
+
let alive = true
|
|
154
|
+
|
|
155
|
+
function say(state, message) {
|
|
156
|
+
root.dataset.agentChatVoiceState = state
|
|
157
|
+
if (status) status.textContent = message || ""
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function embedder() {
|
|
161
|
+
const config = parseEmbedder(root.getAttribute("data-agent-chat-voice-embedder"))
|
|
162
|
+
if (!config) return Promise.resolve(null)
|
|
163
|
+
if (!embedders.has(config.id)) {
|
|
164
|
+
const loading = Promise.resolve().then(() => loadEmbedder(config)).catch(() => null)
|
|
165
|
+
embedders.set(config.id, loading)
|
|
166
|
+
loading.then((loaded) => { if (!loaded) embedders.delete(config.id) })
|
|
167
|
+
}
|
|
168
|
+
return embedders.get(config.id)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function embed(blob) {
|
|
172
|
+
try {
|
|
173
|
+
const loaded = await within(embedder(), EMBED_WAIT_MS, null, setTimer)
|
|
174
|
+
if (!loaded) return {}
|
|
175
|
+
const vector = await within(loaded.embed(blob), EMBED_WAIT_MS, null, setTimer)
|
|
176
|
+
return Array.isArray(vector) && vector.length ? { embedding: vector, model: loaded.id } : {}
|
|
177
|
+
} catch (_) {
|
|
178
|
+
return {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function keepWords(text) {
|
|
183
|
+
const input = form && form.querySelector("[data-agent-chat-input]")
|
|
184
|
+
if (input) input.value = wordsInBox(input.value, text)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function headers(extra = {}) {
|
|
188
|
+
const token = csrfToken(form, doc)
|
|
189
|
+
return { Accept: "application/json", ...extra, ...(token ? { "X-CSRF-Token": token } : {}) }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function send(answer) {
|
|
193
|
+
if (!form) {
|
|
194
|
+
say("failed", MESSAGES.notSent(name))
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
say("sending", MESSAGES.sending(name))
|
|
198
|
+
let response
|
|
199
|
+
try {
|
|
200
|
+
response = await fetcher(form.getAttribute("action"), {
|
|
201
|
+
method: "POST", headers: headers({ "Content-Type": "application/json" }), credentials: "same-origin",
|
|
202
|
+
body: JSON.stringify({ soul, text: answer.text, voice: voiceMetadata(answer) })
|
|
203
|
+
})
|
|
204
|
+
} catch (_) {
|
|
205
|
+
keepWords(answer.text)
|
|
206
|
+
say("failed", MESSAGES.notSent(name))
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const data = await readJson(response)
|
|
210
|
+
if (response.status === 201 && data.turn) {
|
|
211
|
+
// The speaker the server kept, which is what the agent is told.
|
|
212
|
+
const kept = data.voice && typeof data.voice.speaker === "string" ? data.voice.speaker : null
|
|
213
|
+
say("sent", kept ? MESSAGES.sent(kept) : MESSAGES.sentPlain)
|
|
214
|
+
if (chat) chat.dispatchEvent(new CustomEventClass("studio:agent-chat:refresh", { detail: { turn: data.turn } }))
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
keepWords(answer.text)
|
|
218
|
+
const reason = typeof data.error === "string" && data.error ? `${data.error} ` : ""
|
|
219
|
+
say(response.status === 429 ? "rate_limited" : "failed", `${reason}${MESSAGES.notSent(name)}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function handleTake(detail) {
|
|
223
|
+
say("transcribing", MESSAGES.transcribing)
|
|
224
|
+
const vector = await embed(detail.blob)
|
|
225
|
+
let response
|
|
226
|
+
try {
|
|
227
|
+
response = await fetcher(root.dataset.agentChatVoiceTakesUrl || "/voice/takes", {
|
|
228
|
+
method: "POST", headers: headers(), credentials: "same-origin", body: takeForm(detail, vector, FormDataClass)
|
|
229
|
+
})
|
|
230
|
+
} catch (_) {
|
|
231
|
+
say("failed", MESSAGES.offline)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
const body = await readJson(response)
|
|
235
|
+
if (!response.ok || typeof body.text !== "string" || !body.text.trim()) {
|
|
236
|
+
const retryAfter = response.headers && response.headers.get ? response.headers.get("Retry-After") : null
|
|
237
|
+
const { state, message } = response.ok ? { state: "no_speech", message: MESSAGES.noSpeech } : refusalFor(response.status, body, retryAfter)
|
|
238
|
+
say(state, message)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
await send(body)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// One take at a time, in the order they were spoken. Resolves when the queue
|
|
245
|
+
// has drained, so a test can await it.
|
|
246
|
+
async function takeArrived(detail) {
|
|
247
|
+
if (working) {
|
|
248
|
+
waiting.push(detail)
|
|
249
|
+
say("queued", MESSAGES.queued)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
working = true
|
|
253
|
+
try {
|
|
254
|
+
let next = detail
|
|
255
|
+
while (next && alive) {
|
|
256
|
+
await handleTake(next)
|
|
257
|
+
next = waiting.shift()
|
|
258
|
+
}
|
|
259
|
+
} finally {
|
|
260
|
+
working = false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function onTake(event) {
|
|
265
|
+
if (event.detail && event.detail.blob) takeArrived(event.detail)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The one line for a silent take, naming the microphone (the recorder's own
|
|
269
|
+
// line for it is hidden in the composer, studio/agent_chat/_voice).
|
|
270
|
+
function onSilent(event) {
|
|
271
|
+
say("silent", MESSAGES.silent(event.detail && event.detail.microphone && event.detail.microphone.label))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The recorder's state, on the one line: a new hold replaces whatever the last
|
|
275
|
+
// take left there ("Sent.", a refusal). A take still in hand speaks over it
|
|
276
|
+
// when it lands. Starting the model download here gets it ready while the
|
|
277
|
+
// person is still talking.
|
|
278
|
+
function onState(event) {
|
|
279
|
+
const next = event.detail && event.detail.state
|
|
280
|
+
if (next === "starting") {
|
|
281
|
+
say("idle", "")
|
|
282
|
+
} else if (next === "recording") {
|
|
283
|
+
say("listening", MESSAGES.listening)
|
|
284
|
+
embedder().then((loaded) => loaded && loaded.warm && loaded.warm()).catch(() => {})
|
|
285
|
+
} else if (root.dataset.agentChatVoiceState === "listening") {
|
|
286
|
+
say(working ? "transcribing" : "idle", working ? MESSAGES.transcribing : "")
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// "I can't hear anything yet" on the one line: the recorder's own line is hidden
|
|
291
|
+
// in the composer while it records, so a held button never moves for it.
|
|
292
|
+
function onHint(event) {
|
|
293
|
+
const text = event.detail && event.detail.text
|
|
294
|
+
if (text) say("listening", text)
|
|
295
|
+
// Heard after the quiet hint: the recorder's "heard" is blank here, so this takes
|
|
296
|
+
// "I can't hear anything yet" back. No write when the line already says it.
|
|
297
|
+
else if (root.dataset.agentChatVoiceState === "listening" && status && status.textContent !== MESSAGES.listening) say("listening", MESSAGES.listening)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// A Turbo restore brings back whatever the line last said, with no take behind it.
|
|
301
|
+
say("idle", "")
|
|
302
|
+
|
|
303
|
+
root.addEventListener("studio:voice-recorder:take", onTake)
|
|
304
|
+
root.addEventListener("studio:voice-recorder:silent", onSilent)
|
|
305
|
+
root.addEventListener("studio:voice-recorder:state", onState)
|
|
306
|
+
root.addEventListener("studio:voice-recorder:hint", onHint)
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
takeArrived,
|
|
310
|
+
destroy() {
|
|
311
|
+
alive = false
|
|
312
|
+
root.removeEventListener("studio:voice-recorder:take", onTake)
|
|
313
|
+
root.removeEventListener("studio:voice-recorder:silent", onSilent)
|
|
314
|
+
root.removeEventListener("studio:voice-recorder:state", onState)
|
|
315
|
+
root.removeEventListener("studio:voice-recorder:hint", onHint)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "studio/head_chrome"
|
|
26
26
|
import { studioBoard, loadSortable, sortableShim } from "studio/board"
|
|
27
27
|
import { levelingActionModal } from "studio/leveling_activity"
|
|
28
|
-
import {
|
|
28
|
+
import { dsThemeEditor } from "studio/theme_editor"
|
|
29
29
|
import { emailBannerEditor, emailRecipients } from "studio/email_banner"
|
|
30
30
|
|
|
31
31
|
// x-data="navCollapse()": the hub's own header. A host that defines its own
|
|
@@ -60,9 +60,7 @@ export function installAlpineShims() {
|
|
|
60
60
|
// it. SortableJS replaces this object when it arrives; a page that loads its
|
|
61
61
|
// own copy (turf-monster's slate page) replaces it the same way.
|
|
62
62
|
if (!window.Sortable) window.Sortable = sortableShim(() => loadSortable())
|
|
63
|
-
// x-data="themeEditor({...})" (theme_settings/edit) and
|
|
64
63
|
// x-data="dsThemeEditor({...})" (style/_theme). Alpine passes the seed alone.
|
|
65
|
-
if (!window.themeEditor) window.themeEditor = (seed) => themeEditor(seed)
|
|
66
64
|
if (!window.dsThemeEditor) window.dsThemeEditor = (seed) => dsThemeEditor(seed)
|
|
67
65
|
// x-data="emailBannerEditor({...})" (studio/emails/show) and
|
|
68
66
|
// x-data="emailRecipients({...})" (studio/emails/index).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// agent-chat-voice: hold to talk in the shared agent chat's composer
|
|
2
|
+
// (studio/agent_chat/_voice), on the engine's Stimulus application (studio/stimulus
|
|
3
|
+
// registers it lazily, the first time a page renders the mic). The take's journey
|
|
4
|
+
// is studio/agent_chat_voice.
|
|
5
|
+
import { Controller } from "@hotwired/stimulus"
|
|
6
|
+
import { mountAgentChatVoice } from "studio/agent_chat_voice"
|
|
7
|
+
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
connect() {
|
|
10
|
+
this.voice = mountAgentChatVoice(this.element)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
disconnect() {
|
|
14
|
+
if (this.voice) this.voice.destroy()
|
|
15
|
+
this.voice = null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// style-section-nav: the style guide's sticky row (style/index). Its tabs follow
|
|
2
|
+
// the section the reader is in, by scroll and by a tab's own jump.
|
|
3
|
+
//
|
|
4
|
+
// <nav data-studio-controller="style-section-nav">
|
|
5
|
+
// <div data-style-section-nav-target="scroller">
|
|
6
|
+
// <a href="#theme" data-style-section-nav-target="link">Theme</a>
|
|
7
|
+
//
|
|
8
|
+
// Registered by studio/style_guide, which only the style guide loads.
|
|
9
|
+
import { Controller } from "@hotwired/stimulus"
|
|
10
|
+
import { activeIndex } from "studio/style_section_nav"
|
|
11
|
+
|
|
12
|
+
const ACTIVE = "btn-primary"
|
|
13
|
+
const REST = "btn-neutral"
|
|
14
|
+
// A jump lands a section by its scroll-margin (--nav-h + 5rem), read while the
|
|
15
|
+
// header may still be tall; the header then collapses, so the row rises and the
|
|
16
|
+
// section does not. The full margin keeps the clicked section past the line,
|
|
17
|
+
// with the collapse's height to spare, for any later scroll or resize.
|
|
18
|
+
const LINE_SLACK = 80
|
|
19
|
+
|
|
20
|
+
export default class extends Controller {
|
|
21
|
+
static targets = ["link", "scroller"]
|
|
22
|
+
|
|
23
|
+
connect() {
|
|
24
|
+
this.schedule = this.schedule.bind(this)
|
|
25
|
+
window.addEventListener("scroll", this.schedule, { passive: true })
|
|
26
|
+
window.addEventListener("resize", this.schedule)
|
|
27
|
+
this.sync()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
disconnect() {
|
|
31
|
+
window.removeEventListener("scroll", this.schedule)
|
|
32
|
+
window.removeEventListener("resize", this.schedule)
|
|
33
|
+
if (this.frame) cancelAnimationFrame(this.frame)
|
|
34
|
+
this.frame = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
schedule() {
|
|
38
|
+
if (this.frame) return
|
|
39
|
+
this.frame = requestAnimationFrame(() => {
|
|
40
|
+
this.frame = null
|
|
41
|
+
this.sync()
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
sync() {
|
|
46
|
+
const links = this.linkTargets
|
|
47
|
+
const tops = links.map((link) => {
|
|
48
|
+
const section = document.getElementById(link.getAttribute("href").slice(1))
|
|
49
|
+
return section ? section.getBoundingClientRect().top : Infinity
|
|
50
|
+
})
|
|
51
|
+
const line = this.element.getBoundingClientRect().bottom + LINE_SLACK
|
|
52
|
+
const page = document.documentElement
|
|
53
|
+
const atPageEnd = page.scrollTop > 0 && window.innerHeight + page.scrollTop >= page.scrollHeight - 2
|
|
54
|
+
this.highlight(links[activeIndex(tops, line, atPageEnd)])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
highlight(current) {
|
|
58
|
+
if (!current || current === this.current) return
|
|
59
|
+
this.current = current
|
|
60
|
+
this.linkTargets.forEach((link) => {
|
|
61
|
+
const on = link === current
|
|
62
|
+
link.classList.toggle(ACTIVE, on)
|
|
63
|
+
link.classList.toggle(REST, !on)
|
|
64
|
+
if (on) link.setAttribute("aria-current", "true")
|
|
65
|
+
else link.removeAttribute("aria-current")
|
|
66
|
+
})
|
|
67
|
+
this.reveal(current)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Keeps the active tab in view on a narrow screen. Moves the row only:
|
|
71
|
+
// scrollIntoView would also move the page the reader is scrolling.
|
|
72
|
+
reveal(link) {
|
|
73
|
+
if (!this.hasScrollerTarget) return
|
|
74
|
+
const row = this.scrollerTarget
|
|
75
|
+
const box = row.getBoundingClientRect()
|
|
76
|
+
const tab = link.getBoundingClientRect()
|
|
77
|
+
if (tab.left < box.left) row.scrollLeft -= box.left - tab.left + 32
|
|
78
|
+
else if (tab.right > box.right) row.scrollLeft += tab.right - box.right + 32
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -325,6 +325,7 @@ export function mountMicrophoneChip(root, { win = window } = {}) {
|
|
|
325
325
|
if (!target.closest) return
|
|
326
326
|
// A click on the backdrop lands on the dialog element itself.
|
|
327
327
|
if (dialog && target === dialog) { dialog.close(); return }
|
|
328
|
+
if (dialog && target.closest("[data-microphone-help-close]")) { dialog.close(); return }
|
|
328
329
|
const option = target.closest("[data-microphone-option]")
|
|
329
330
|
if (option && root.contains(option)) { pick(option); return }
|
|
330
331
|
if (target.closest("[data-microphone-help-link]")) {
|
|
@@ -63,6 +63,8 @@ export const schema = {
|
|
|
63
63
|
// So are the voice capture pair (docs/VOICE_CAPTURE.md): a chip whose controller
|
|
64
64
|
// failed still shows its prompt line, and a recorder whose controller failed
|
|
65
65
|
// says recording did not load (data-studio-controller-failed="voice-recorder").
|
|
66
|
+
// So is the chat's mic: a mic whose controller failed says voice did not load,
|
|
67
|
+
// and the box still types (data-studio-controller-failed="agent-chat-voice").
|
|
66
68
|
export const LAZY = {
|
|
67
69
|
"board": () => import("studio/controllers/board_controller"),
|
|
68
70
|
"geo-settings": () => import("studio/controllers/geo_settings_controller"),
|
|
@@ -73,6 +75,7 @@ export const LAZY = {
|
|
|
73
75
|
"survey": () => import("studio/controllers/survey_controller"),
|
|
74
76
|
"knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
|
|
75
77
|
"agent-chat": () => import("studio/controllers/agent_chat_controller"),
|
|
78
|
+
"agent-chat-voice": () => import("studio/controllers/agent_chat_voice_controller"),
|
|
76
79
|
"voice-profile": () => import("studio/controllers/voice_profile_controller"),
|
|
77
80
|
"microphone-chip": () => import("studio/controllers/microphone_chip_controller"),
|
|
78
81
|
"voice-recorder": () => import("studio/controllers/voice_recorder_controller")
|
|
@@ -9,5 +9,7 @@
|
|
|
9
9
|
// demos the moment the page is up finds them there.
|
|
10
10
|
import { application } from "studio/stimulus"
|
|
11
11
|
import StyleModalsController from "studio/controllers/style_modals_controller"
|
|
12
|
+
import StyleSectionNavController from "studio/controllers/style_section_nav_controller"
|
|
12
13
|
|
|
13
14
|
application.register("style-modals", StyleModalsController)
|
|
15
|
+
application.register("style-section-nav", StyleSectionNavController)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// studio/style_section_nav: which section of the style guide the reader is in.
|
|
2
|
+
// It imports nothing; test/javascript/style_section_nav.test.mjs loads it as a
|
|
3
|
+
// data: module.
|
|
4
|
+
//
|
|
5
|
+
// `tops` is each section's top in viewport pixels, in page order. A section is
|
|
6
|
+
// current once its top reaches the bottom of the sticky row (`line`). Before
|
|
7
|
+
// the first one reaches it, the first is current. At the end of the page the
|
|
8
|
+
// last is current, because a short last section can never reach the line.
|
|
9
|
+
export function activeIndex(tops, line, atPageEnd = false) {
|
|
10
|
+
if (tops.length === 0) return -1
|
|
11
|
+
if (atPageEnd) return tops.length - 1
|
|
12
|
+
|
|
13
|
+
let active = 0
|
|
14
|
+
tops.forEach((top, index) => { if (top <= line) active = index })
|
|
15
|
+
return active
|
|
16
|
+
}
|
|
@@ -1,72 +1,18 @@
|
|
|
1
|
-
// studio/theme_editor: the
|
|
2
|
-
//
|
|
1
|
+
// studio/theme_editor: the live-preview theme editor, as the Alpine factory its
|
|
2
|
+
// template binds.
|
|
3
3
|
//
|
|
4
|
-
// themeEditor(colors) theme_settings/edit, the standalone /admin/theme page
|
|
5
4
|
// dsThemeEditor(seed) style/_theme, the Theme section of the style guide
|
|
6
5
|
//
|
|
7
|
-
//
|
|
6
|
+
// It is seeded with the server-resolved role colours and writes the themeable
|
|
8
7
|
// role variables on the document as the operator types, so every specimen on
|
|
9
8
|
// the page restyles at once. The inputs still carry the real values.
|
|
10
9
|
//
|
|
11
|
-
// studio/alpine_shims publishes
|
|
12
|
-
// because x-data="
|
|
10
|
+
// studio/alpine_shims publishes it as a window global before Alpine starts,
|
|
11
|
+
// because x-data="dsThemeEditor(...)" is evaluated the moment Alpine does.
|
|
13
12
|
//
|
|
14
13
|
// It imports nothing; test/javascript/theme_editor.test.mjs loads it as a
|
|
15
14
|
// data: module.
|
|
16
15
|
|
|
17
|
-
// The seven roles the standalone editor edits, in the order it lists them.
|
|
18
|
-
export const ROLES = ["primary", "success", "accent", "warning", "danger", "dark", "light"]
|
|
19
|
-
|
|
20
|
-
// A typed colour as #rrggbb: a missing # is added and a 3-digit hex is doubled.
|
|
21
|
-
// Answers null for anything that is not a 3- or 6-digit hex.
|
|
22
|
-
export function normalizeHex(value) {
|
|
23
|
-
let hex = String(value == null ? "" : value).trim()
|
|
24
|
-
if (!hex.startsWith("#")) hex = "#" + hex
|
|
25
|
-
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
|
|
26
|
-
hex = "#" + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
|
27
|
-
}
|
|
28
|
-
return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// `hex` (#rrggbb) moved `amount` (0..1) of the way to white.
|
|
32
|
-
export function lightenColor(hex, amount) {
|
|
33
|
-
hex = hex.replace("#", "")
|
|
34
|
-
const r = parseInt(hex.substring(0, 2), 16)
|
|
35
|
-
const g = parseInt(hex.substring(2, 4), 16)
|
|
36
|
-
const b = parseInt(hex.substring(4, 6), 16)
|
|
37
|
-
const nr = Math.round(r + (255 - r) * amount)
|
|
38
|
-
const ng = Math.round(g + (255 - g) * amount)
|
|
39
|
-
const nb = Math.round(b + (255 - b) * amount)
|
|
40
|
-
return "#" + [nr, ng, nb].map((c) => c.toString(16).padStart(2, "0")).join("")
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// The standalone editor's colours: every role present, blank when the server
|
|
44
|
-
// resolved none.
|
|
45
|
-
export function seededColors(seed) {
|
|
46
|
-
const colors = {}
|
|
47
|
-
ROLES.forEach((role) => { colors[role] = (seed && seed[role]) || "" })
|
|
48
|
-
return colors
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// What the standalone editor writes on the root for `colors`, as
|
|
52
|
-
// [property, value] pairs; a null value removes the property.
|
|
53
|
-
//
|
|
54
|
-
// btn-warning and btn-danger paint --color-<role>-fill first (the resolver
|
|
55
|
-
// emits it for the engine defaults), so a typed colour sets it too or the
|
|
56
|
-
// specimens keep the default fill. A cleared colour drops the override.
|
|
57
|
-
export function previewProperties(colors) {
|
|
58
|
-
const properties = [
|
|
59
|
-
["--color-cta", colors.primary],
|
|
60
|
-
["--color-success", colors.success],
|
|
61
|
-
["--color-warning", colors.warning || "#FF7C47"],
|
|
62
|
-
["--color-danger", colors.danger || "#EF4444"]
|
|
63
|
-
]
|
|
64
|
-
for (const role of ["warning", "danger"]) {
|
|
65
|
-
properties.push([`--color-${role}-fill`, colors[role] ? colors[role] : null])
|
|
66
|
-
}
|
|
67
|
-
return properties
|
|
68
|
-
}
|
|
69
|
-
|
|
70
16
|
// What the style guide's editor writes on the root for `colors`. It sets only
|
|
71
17
|
// what has a value and removes nothing.
|
|
72
18
|
export function stylePreviewProperties(colors) {
|
|
@@ -87,52 +33,6 @@ function applyProperties(style, properties) {
|
|
|
87
33
|
}
|
|
88
34
|
}
|
|
89
35
|
|
|
90
|
-
// x-data="themeEditor({...})": theme_settings/edit.
|
|
91
|
-
export function themeEditor(seed) {
|
|
92
|
-
return {
|
|
93
|
-
colors: seededColors(seed),
|
|
94
|
-
|
|
95
|
-
resetColor(role, defaultVal) {
|
|
96
|
-
this.colors[role] = defaultVal
|
|
97
|
-
this.updatePreview()
|
|
98
|
-
},
|
|
99
|
-
|
|
100
|
-
normalizeHex(role) {
|
|
101
|
-
const hex = normalizeHex(this.colors[role])
|
|
102
|
-
if (hex) this.colors[role] = hex
|
|
103
|
-
this.updatePreview()
|
|
104
|
-
},
|
|
105
|
-
|
|
106
|
-
// Live CSS variables on the document, for immediate feedback.
|
|
107
|
-
updatePreview() {
|
|
108
|
-
applyProperties(document.documentElement.style, previewProperties(this.colors))
|
|
109
|
-
},
|
|
110
|
-
|
|
111
|
-
darkPreviewStyle() {
|
|
112
|
-
return { backgroundColor: this.colors.dark, borderRadius: "0" }
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
darkSurfaceStyle() {
|
|
116
|
-
return {
|
|
117
|
-
backgroundColor: this.lightenColor(this.colors.dark, 0.3),
|
|
118
|
-
border: "1px solid rgba(255,255,255,0.1)"
|
|
119
|
-
}
|
|
120
|
-
},
|
|
121
|
-
|
|
122
|
-
lightPreviewStyle() {
|
|
123
|
-
return { backgroundColor: this.colors.light, borderRadius: "0" }
|
|
124
|
-
},
|
|
125
|
-
|
|
126
|
-
lightSurfaceStyle() {
|
|
127
|
-
return { backgroundColor: "#ffffff", border: "1px solid #e2e8f0" }
|
|
128
|
-
},
|
|
129
|
-
|
|
130
|
-
lightenColor(hex, amount) {
|
|
131
|
-
return lightenColor(hex, amount)
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
36
|
// The method a form really sends, and its body without Rails' _method override.
|
|
137
37
|
// The editor's form carries _method=patch, and sending the real method means
|
|
138
38
|
// nothing depends on Rack::MethodOverride parsing a multipart fetch body.
|
|
@@ -170,7 +70,7 @@ export function failureReason(response) {
|
|
|
170
70
|
//
|
|
171
71
|
// SAVE IN PLACE. Save and Regenerate submit by fetch and stay on the style
|
|
172
72
|
// guide: the form's @submit.prevent stops the browser navigating to
|
|
173
|
-
//
|
|
73
|
+
// its save endpoint, the live preview persists, and the outcome is a toast (the
|
|
174
74
|
// engine's `toast` window event). The form's own POST still works when Alpine
|
|
175
75
|
// never loads.
|
|
176
76
|
export function dsThemeEditor(seed) {
|