1import { Controller } from "@hotwired/stimulus" 2 3// Module-level singleton â survives Stimulus connect/disconnect. 4// 5// data-turbo-permanent keeps our wrapper in the DOM across Turbo 6// navigations, but in practice Turbo briefly detaches and re-attaches 7// the element during the page swap. Stimulus's MutationObserver fires 8// disconnect then connect, which would tear down the WebRTC peer 9// connection and force the user to start the session again on every 10// navigation. 11// 12// Storing the session here (outside the controller instance) means the 13// pc/dc/micStream/audioEl survive any number of Stimulus 14// reconnections. The new controller instance just rebinds the data 15// channel handler to itself and resumes â same conversation, same 16// activeResponseId, same screen context history. 17// 18// Cleared on actual page unload (the listener below) or when the user 19// explicitly taps stop. 20let SESSION = null 21 22window.addEventListener("beforeunload", () => { 23 if (!SESSION) return 24 try { SESSION.dc?.close() } catch (_) {} 25 try { SESSION.pc?.close() } catch (_) {} 26 SESSION.micStream?.getTracks().forEach((t) => t.stop()) 27 if (SESSION.audioEl) { SESSION.audioEl.srcObject = null; SESSION.audioEl.remove() } 28 SESSION = null 29}) 30 31// Admin voice assistant. 32// 33// Mounts a big floating mic button on every admin page. Tapping the 34// button opens an OpenAI Realtime session over WebRTC and pipes mic 35// audio in / model audio out. The data channel carries events: 36// transcript deltas, speech-start/stop, and function_call_arguments.done. 37// 38// Function calls are routed two ways: 39// 1. Client-side handlers (`save_form`, `scroll_to_block`, every 40// `form__*` tool) â DOM tools that don't need a server round-trip. 41// 2. Server-side handlers (every `platform_blocks__*` / 42// `catalogue__*` / `navigate__*` tool) â POSTed to 43// /admin/ai-tools/invoke, which dispatches through 44// AdminAi::ToolRegistry under the admin's tenant. 45// 46// `form__*` tools let the AI fill in any visible form on the page â 47// title fields, textareas, dates, selects, checkboxes â so its reach 48// isn't limited to the platform editor. Every admin form is auto- 49// discovered (collectVisibleForms below), surfaced to the AI in the 50// per-page screen context, and edited via setFormFieldValue with 51// proper input/change events so Turbo and downstream Stimulus 52// controllers see the change as if the user had typed it. 53// 54// The result of every tool call is sent back over the data channel as a 55// function_call_output item, then we trigger response.create so the AI 56// speaks the result. 57// 58// State machine for the visual button: 59// idle â not connected, mic off 60// connecting â WebRTC handshake in flight 61// listening â connected, user can speak 62// hearing â VAD detected user speech 63// thinking â user finished, waiting for response 64// speaking â AI is talking 65// tool â AI invoked a tool 66// error â handshake or stream failed 67export default class extends Controller { 68 static targets = [ 69 "button", "status", "transcript", "indicator", "panel", "copyButton", 70 "composer", "composerInput", "composerSend", 71 "picker", "pickerBackdrop", "pickerList", "pickerTitle", "pickerSubtitle", 72 "pickerSearch", "pickerCount", "pickerFooter", "pickerConfirm", "pickerHint" 73 ] 74 static values = { 75 sessionUrl: { type: String, default: "/admin/openai-sessions" }, 76 csrfUrl: { type: String, default: "/admin/csrf-token" }, 77 invokeUrl: { type: String, default: "/admin/ai-tools/invoke" } 78 } 79 80 connect() { 81 // Turbo navigation hook: re-collect screen context after each 82 // page load so the AI knows where the user landed. 83 this.boundTurboLoad = () => this.onTurboLoad() 84 document.addEventListener("turbo:load", this.boundTurboLoad) 85 86 if (SESSION && SESSION.pc) { 87 // We're a fresh controller instance taking over an existing 88 // session (Turbo just swapped the page; SESSION survived 89 // because it lives outside the controller). 90 this.adoptSession() 91 } else { 92 // Fresh page, no session yet. Set defaults; user taps the 93 // button to actually open the realtime connection. 94 this.state = "idle" 95 this.aiSpeaking = false 96 this.activeResponseId = null 97 this.pendingResponseCreate = false 98 this.pc = null 99 this.dc = null 100 this.micStream = null 101 this.audioEl = null 102 this.renderState() 103 } 104 } 105 106 disconnect() { 107 if (this.boundTurboLoad) document.removeEventListener("turbo:load", this.boundTurboLoad) 108 // A product picker is page-specific â don't carry a stale one across 109 // a Turbo navigation. 110 this.closePicker() 111 112 // Don't tear down here. Stimulus disconnects this controller every 113 // time Turbo briefly detaches our wrapper during a page swap â
114 // we'd kill the WebRTC session on every navigation. Instead, 115 // snapshot the live state to SESSION (so the next controller 116 // instance can pick it back up) and detach handlers so the dead 117 // controller doesn't get more events. 118 if (this.pc && SESSION) { 119 SESSION.state = this.state 120 SESSION.aiSpeaking = this.aiSpeaking 121 SESSION.activeResponseId = this.activeResponseId 122 SESSION.pendingResponseCreate = this.pendingResponseCreate 123 SESSION.screen = this.screen 124 // Pause events during the gap â the new controller will re-bind 125 // its own handler when it adopts the session. 126 try { if (this.dc) this.dc.onmessage = null } catch (_) {} 127 try { if (this.pc) this.pc.onconnectionstatechange = null } catch (_) {} 128 } 129 // Real page unload is handled by the module-level beforeunload 130 // listener at the top of this file â it tears the session down 131 // for good. 132 } 133 134 // Re-bind UI + handlers to the singleton session that survived 135 // Turbo's element swap. Called from connect() when SESSION already 136 // holds an active peer connection. 137 adoptSession() { 138 this.pc = SESSION.pc 139 this.dc = SESSION.dc 140 this.micStream = SESSION.micStream 141 this.audioEl = SESSION.audioEl 142 this.state = SESSION.state || "listening" 143 this.aiSpeaking = SESSION.aiSpeaking || false 144 this.activeResponseId = SESSION.activeResponseId || null 145 this.pendingResponseCreate = SESSION.pendingResponseCreate || false 146 this.screen = SESSION.screen 147 148 if (this.dc) { 149 this.dc.onmessage = (e) => { 150 try { this.handleEvent(JSON.parse(e.data)) } 151 catch (err) { console.warn("[admin-voice] bad event:", err) } 152 } 153 } 154 if (this.pc) { 155 this.pc.onconnectionstatechange = () => { 156 if (["disconnected", "failed", "closed"].includes(this.pc.connectionState)) { 157 if (this.state !== "idle") this.setState("error", "Connection lost") 158 this.tearDown() 159 } 160 } 161 } 162 163 // The audio element rode along inside our data-turbo-permanent 164 // wrapper, so it still has the remote stream attached. Belt and 165 // suspenders: re-home it under this.element in case anything parked 166 // it elsewhere, and if the brief detach/reattach during the page 167 // swap paused it, nudge it back into play (a no-op if it never 168 // stopped; the .catch swallows an autoplay-policy rejection). 169 if (this.audioEl) { 170 if (this.audioEl.parentNode !== this.element) this.element.appendChild(this.audioEl) 171 if (this.audioEl.paused) { try { this.audioEl.play().catch(() => {}) } catch (_) {} } 172 } 173 174 if (this.hasPanelTarget) this.panelTarget.classList.remove("hidden") 175 this.setStatus(labelForState(this.state)) 176 this.renderState() 177 178 // Defensive push: turbo:load normally fires AFTER Stimulus 179 // reconnects (so onTurboLoad below catches it), but in rare cases 180 // the order swaps â there's a brief window between the old 181 // controller's disconnect (which removed its turbo:load listener) 182 // and our connect() below. Re-collect + push now so the model is 183 // always told about the new page exactly once. A duplicate context 184 // message (if turbo:load also fires) is harmless. 185 this.screen = this.collectScreenContext() 186 if (SESSION) SESSION.screen = this.screen 187 if (this.dc?.readyState === "open") { 188 this.pushScreenContextToModel(this.screen) 189 } 190 } 191 192 // Called after Turbo lands on a new page (or the initial page load). 193 // We re-collect screen context and quietly inject it into the 194 // realtime conversation as a system-style note so the AI knows 195 // where it took the user. 196 onTurboLoad() { 197 this.screen = this.collectScreenContext() 198 if (SESSION) SESSION.screen = this.screen 199 if (this.dc && this.dc.readyState === "open") { 200 this.pushScreenContextToModel(this.screen) 201 } 202 } 203 204 // Push a [screen context] item into the realtime conversation. 205 // Doesn't trigger a response â just gives the model fresh facts to 206 // use next time it speaks or calls a tool. 207 pushScreenContextToModel(screen) { 208 if (!this.dc || this.dc.readyState !== "open") return 209 const summary = formatScreenContext(screen) 210 try { 211 this.dc.send(JSON.stringify({ 212 type: "conversation.item.create", 213 item: { 214 type: "message", 215 role: "system", 216 content: [{type: "input_text", text: summary}] 217 } 218 })) 219 } catch (err) { 220 console.warn("[admin-voice] failed to push screen context:", err) 221 } 222 } 223 224 // Single button entry point: tap to start, tap again to stop.
225 async toggle() { 226 if (this.state === "idle" || this.state === "error") { 227 await this.start() 228 } else { 229 this.stop() 230 } 231 } 232 233 async start() { 234 if (this.state !== "idle" && this.state !== "error") return 235 this.setState("connecting", "Connectingâ¦") 236 if (this.hasPanelTarget) this.panelTarget.classList.remove("hidden") 237 this.appendTranscript("system", "Connecting to voice assistantâ¦") 238 239 try { 240 this.pc = new RTCPeerConnection() 241 242 // Remote audio = AI voice. Mount the <audio> element INSIDE our 243 // wrapper (#admin-voice-agent), not on <body>. The wrapper is 244 // data-turbo-permanent, so Turbo relocates it â and this audio 245 // child with it â into the new page during a navigation, without 246 // it ever really leaving the document. If we appended to <body> 247 // instead, Turbo's full-body swap would throw the audio element 248 // away on the first navigation (the browser pauses a media element 249 // the moment it's removed from the document), and since pc.ontrack 250 // only fires once, nothing would ever rebind the stream â playback 251 // would stay dead until the user restarted the session. Keeping it 252 // in the permanent wrapper is what makes the AI keep talking across 253 // pages. Hidden so it never affects the FAB's flex layout. 254 this.audioEl = document.createElement("audio") 255 this.audioEl.autoplay = true 256 this.audioEl.hidden = true 257 this.audioEl.dataset.adminVoice = "audio" 258 this.element.appendChild(this.audioEl) 259 this.pc.ontrack = (e) => { this.audioEl.srcObject = e.streams[0] } 260 261 // Mic in 262 this.micStream = await navigator.mediaDevices.getUserMedia({ audio: true }) 263 this.pc.addTrack(this.micStream.getTracks()[0]) 264 265 // Data channel for events + tool calls 266 this.dc = this.pc.createDataChannel("oai-events") 267 this.dc.onmessage = (e) => { 268 try { this.handleEvent(JSON.parse(e.data)) } 269 catch (err) { console.warn("[admin-voice] bad event:", err, e.data) } 270 } 271 // Race-safe context push: by the time the channel actually opens, 272 // the user may have already navigated (Turbo) or the DOM may have 273 // mutated (Stimulus / Turbo Frames). Re-collect screen context 274 // here so the very first thing the AI sees over the channel is a 275 // fresh forms+page snapshot â guaranteed to land before any user 276 // audio can produce a response. 277 this.dc.addEventListener("open", () => { 278 try { 279 this.screen = this.collectScreenContext() 280 if (SESSION) SESSION.screen = this.screen 281 this.pushScreenContextToModel(this.screen) 282 } catch (err) { 283 console.warn("[admin-voice] dc.open context push failed:", err) 284 } 285 }) 286 287 this.pc.onconnectionstatechange = () => { 288 if (["disconnected", "failed", "closed"].includes(this.pc.connectionState)) { 289 if (this.state !== "idle") this.setState("error", "Connection lost") 290 this.tearDown() 291 } 292 } 293 294 // Offer â server â answer 295 const offer = await this.pc.createOffer() 296 await this.pc.setLocalDescription(offer) 297 298 const csrf = await this.refreshCsrfToken() 299 const screen = this.collectScreenContext() 300 // CloudFront caps each request header around 10KB. Admin pages with 301 // lots of forms (e.g. /admin/suppliers) blow past that and trip a 302 // 494 before the request ever reaches Rails. Encode, measure, and 303 // shed the heaviest fields until it fits. The data channel re-pushes 304 // the full context the moment it opens (see dc.addEventListener 305 // "open" above), so dropping `forms` and `visible_blocks` here only 306 // delays them â it doesn't lose them. 307 const HEADER_BUDGET = 6000 308 let screenForHeader = screen 309 let encoded = encodeURIComponent(JSON.stringify(screenForHeader)) 310 if (encoded.length > HEADER_BUDGET && screenForHeader.forms) { 311 const {forms, ...rest} = screenForHeader 312 screenForHeader = rest 313 encoded = encodeURIComponent(JSON.stringify(screenForHeader)) 314 } 315 if (encoded.length > HEADER_BUDGET && screenForHeader.visible_blocks) { 316 const {visible_blocks, ...rest} = screenForHeader 317 screenForHeader = rest 318 encoded = encodeURIComponent(JSON.stringify(screenForHeader)) 319 } 320 const response = await fetch(this.sessionUrlValue, { 321 method: "POST", 322 credentials: "same-origin", 323 body: offer.sdp, 324 headers: { 325 "Content-Type": "application/sdp", 326 "X-CSRF-Token": csrf, 327 // HTTP header values must be ISO-8859-1. The screen context is 328 // built from page content (product titles, descriptions, form 329 // field values) which routinely contains em-dashes, curly 330 // quotes and accents â passing raw JSON throws "String contains 331 // non ISO-8859-1 code point" in fetch. Percent-encode here; 332 // OpenaiSessionsController#parse_screen decodes it. 333 "X-Voice-Screen": encoded 334 } 335 }) 336 if (!response.ok) { 337 const text = await response.text() 338 throw new Error(`Session ${response.status}: ${text}`) 339 } 340 const answer = { type: "answer", sdp: await response.text() } 341 await this.pc.setRemoteDescription(answer) 342 343 this.screen = screen 344 // Promote the session to module scope so it outlives any 345 // Stimulus disconnect/reconnect cycles caused by Turbo. 346 SESSION = { 347 pc: this.pc, 348 dc: this.dc, 349 micStream: this.micStream, 350 audioEl: this.audioEl, 351 screen: this.screen, 352 state: "listening", 353 aiSpeaking: false, 354 activeResponseId: null, 355 pendingResponseCreate: false 356 } 357 this.setState("listening", "Listening â say something") 358 this.appendTranscript("system", "Connected. Try: \"add a featured products block\", \"list the blocks\", \"hide the FAQ\".") 359 } catch (err) { 360 console.error("[admin-voice] start failed:", err) 361 this.setState("error", `Couldn't start voice: ${err.message}`) 362 this.appendTranscript("system", `Error: ${err.message}`) 363 this.tearDown() 364 } 365 } 366 367 stop() { 368 this.setState("idle", "Tap to talk") 369 this.tearDown() 370 this.appendTranscript("system", "Session ended.") 371 } 372 373 tearDown() { 374 this.closePicker() 375 if (this.micStream) {
376 this.micStream.getTracks().forEach((t) => t.stop()) 377 this.micStream = null 378 } 379 if (this.dc) { try { this.dc.close() } catch (_) {} this.dc = null } 380 if (this.pc) { try { this.pc.close() } catch (_) {} this.pc = null } 381 if (this.audioEl) { 382 this.audioEl.srcObject = null 383 this.audioEl.remove() 384 this.audioEl = null 385 } 386 // Also clear the module-level singleton so the next start() opens 387 // a fresh connection (and a Turbo navigation in between sees no 388 // session to adopt). 389 SESSION = null 390 } 391 392 // ââ Event handler ââââââââââââââââââââââââââââââââââââââââââââ 393 394 handleEvent(event) { 395 switch (event.type) { 396 case "input_audio_buffer.speech_started": 397 // VAD fired: the user is talking. Always reflect this â even 398 // if the AI is currently speaking â so the button visibly 399 // turns green and the user knows they were heard. 400 this.setState("hearing", "Hearing youâ¦") 401 // If the AI is talking, the user wants to interrupt. Cancel 402 // the in-flight response so the model shuts up and listens. 403 if (this.aiSpeaking && this.activeResponseId && this.dc?.readyState === "open") { 404 try { 405 this.dc.send(JSON.stringify({type: "response.cancel"})) 406 } catch (_) { /* ignore */ } 407 this.aiSpeaking = false 408 } 409 break 410 411 case "input_audio_buffer.speech_stopped": 412 this.setState("thinking", "Thinkingâ¦") 413 break 414 415 case "response.created": 416 this.activeResponseId = event.response?.id || event.response_id || "active" 417 if (!this.aiSpeaking) { 418 this.aiSpeaking = true 419 this.setState("speaking", "Speakingâ¦") 420 } 421 break 422 423 case "response.output_item.added": 424 if (!this.aiSpeaking) { 425 this.aiSpeaking = true 426 this.setState("speaking", "Speakingâ¦") 427 } 428 break 429 430 case "response.audio_transcript.delta": 431 case "response.output_audio_transcript.delta": 432 if (!this.aiSpeaking) { 433 this.aiSpeaking = true 434 this.setState("speaking", "Speakingâ¦") 435 } 436 this.appendTranscriptDelta(event.delta) 437 break 438 439 case "response.audio_transcript.done": 440 case "response.output_audio_transcript.done": 441 this.finalizeAssistantTranscript() 442 break 443 444 case "conversation.item.input_audio_transcription.delta": 445 // User's own speech transcribed back â useful in the panel. 446 this.appendUserTranscriptDelta(event.delta) 447 break 448 449 case "conversation.item.input_audio_transcription.completed": 450 this.finalizeUserTranscript(event.transcript) 451 break 452 453 case "response.function_call_arguments.done": 454 this.setStatus(`Running ${humanizeName(event.name)}â¦`) 455 break 456 457 case "response.output_item.done": 458 if (event.item && event.item.type === "function_call" && event.item.status === "completed") { 459 this.executeFunction(event.item.name, event.item.arguments, event.item.call_id) 460 } 461 break 462 463 case "response.done": 464 this.activeResponseId = null 465 this.aiSpeaking = false 466 // If tool outputs landed while a response was in flight, the 467 // model needs a fresh `response.create` to speak the result. 468 // Fire it now, but only once even if multiple outputs queued. 469 if (this.pendingResponseCreate && this.dc?.readyState === "open") { 470 this.pendingResponseCreate = false 471 this.dc.send(JSON.stringify({type: "response.create"})) 472 } else if (this.state === "speaking" || this.state === "tool" || this.state === "thinking") { 473 this.setState("listening", "Listening â say something") 474 } 475 break 476 477 case "output_audio_buffer.stopped": 478 this.aiSpeaking = false 479 if (this.state === "speaking" || this.state === "tool" || this.state === "thinking") { 480 this.setState("listening", "Listening â say something") 481 } 482 break 483 484 case "error": 485 // Benign barge-in race: when the user talks over the assistant 486 // we optimistically send `response.cancel` (see 487 // speech_started above), but the response sometimes finishes 488 // server-side a beat earlier â the API then replies 489 // "response_cancel_not_active". Nothing's broken; the assistant 490 // is already quiet, so don't alarm the user with it. 491 if (event.error?.code === "response_cancel_not_active") { 492 console.debug("[admin-voice] response.cancel raced a finishing response
492(ignored)") 493 break 494 } 495 console.error("[admin-voice] realtime error:", event) 496 this.appendTranscript("system", `Error: ${event.error?.message || "unknown"}`) 497 break 498 } 499 500 // Mirror response/speech state back to the singleton so the next 501 // controller instance (after a Turbo nav) picks up exactly where 502 // we left off. 503 if (SESSION) { 504 SESSION.aiSpeaking = this.aiSpeaking 505 SESSION.activeResponseId = this.activeResponseId 506 SESSION.pendingResponseCreate = this.pendingResponseCreate 507 } 508 } 509 510 // ââ Tool execution ââââââââââââââââââââââââââââââââââââââââââââ 511 512 async executeFunction(name, argsJson, callId) { 513 this.setState("tool", `Running ${humanizeName(name)}â¦`) 514 let args 515 try { args = JSON.parse(argsJson || "{}") } catch (_) { args = {} } 516 517 // Log the call in the transcript panel (and so it lands in the 518 // copy-to-clipboard dump). The on-screen text is a short preview; 519 // the full arguments are stashed for the clipboard. 520 this.appendToolCall(name, args) 521 522 let result 523 try { 524 const clientHandler = this.clientHandlers[name] 525 if (clientHandler) { 526 result = await clientHandler.call(this, args) 527 } else { 528 // Default path: send to the server registry. 529 const r = await fetch(this.invokeUrlValue, { 530 method: "POST", 531 credentials: "same-origin", 532 headers: { 533 "Content-Type": "application/json", 534 "Accept": "application/json", 535 "X-CSRF-Token": await this.refreshCsrfToken(), 536 "X-Requested-With": "XMLHttpRequest" 537 }, 538 body: JSON.stringify({ 539 name: name, 540 args: args, 541 tool_call_id: callId, 542 screen: this.screen || this.collectScreenContext() 543 }) 544 }) 545 result = r.ok 546 ? await r.json() 547 : { ok: false, error: `HTTP ${r.status}` } 548 } 549 } catch (err) { 550 result = { ok: false, error: err.message } 551 } 552 553 this.appendToolResult(name, result) 554 this.handleToolSideEffects(result) 555 this.sendToolResult(callId, result) 556 } 557 558 // Three flavours of side effect: 559 // navigate: take the user to a new page via Turbo (preserves the 560 // voice session â voice agent wrapper is data-turbo-permanent). 561 // refresh_block: a single block was mutated. Surgically update the 562 // block's row inputs in place (keeps focus/scroll), reveal the 563 // body, flash-highlight the changed row, and trigger the existing 564 // platform-editor live-preview update so the right pane repaints. 565 // refresh_page: a structural change (add/delete/reorder blocks). 566 // Fall back to softRefreshPage, but preserve which block bodies 567 // were expanded and re-expand the affected block afterwards. 568 async handleToolSideEffects(result) { 569 if (!result || result.ok === false) return 570 571 // Resource picker â pops a centered modal of matches (products, 572 // collections, suppliers, â¦) so the user can hand one or many back 573 // to the AI (see showPicker). Newer tool responses send 574 // result.items + result.picker_type; older product-only responses 575 // still send result.products. Either is accepted. 576 const pickerKind = result.picker_type 577 const pickerItems = Array.isArray(result.items) && result.items.length 578 ? result.items 579 : (Array.isArray(result.products) && result.products.length 580 ? result.products 581 : null) 582 if (result.picker && pickerItems) { 583 this.showPicker(pickerItems, { 584 query: result.query, 585 multi: result.picker_mode === "multi", 586 prompt: result.picker_prompt, 587 kind: pickerKind || "product", 588 context: result.picker_context || null 589 }) 590 return 591 } 592 593 if (result.navigate) { 594 this.performNavigation(result.navigate) 595 return 596 } 597 598 if (result.refresh_block && Array.isArray(result.items) && result.block_id != null) { 599 this.applyBlockUpdate(result) 600 this.requestPreviewUpdate() 601 return 602 } 603 604 if (result.refresh_page) { 605 await this.softRefreshPage({ 606 focusBlockId: result.block_id, 607 focusItemIndex: result.item_index, 608 deletedBlockId: result.deleted_block_id 609 }) 610 this.requestPreviewUpdate() 611 } 612 } 613 614 // Navigate via Turbo if it's loaded, falling back to window.location 615 // for cases where Turbo isn't installed (it is, but defensive). 616 performNavigation({ url, action } = {}) { 617 if (action === "back") { 618 window.history.back() 619 return 620 } 621 if (!url) return 622 if (window.Turbo && typeof window.Turbo.visit === "function") { 623 window.Turbo.visit(url) 624 } else { 625 window.location.href = url 626 } 627 } 628 629 // Surgically rewrite a single block's row inputs from the new items 630 // array the server returned. No DOM swap â preserves focus, scroll 631 // position and Stimulus controller state. 632 applyBlockUpdate(result) { 633 const blockEl = document.querySelector( 634 `#platform-blocks [data-block-id="${result.block_id}"]` 635 ) 636 if (!blockEl) { 637 // Block isn't on this page (maybe the user navigated). Fall back 638 // to a full refresh so the next mutation lands somewhere sane. 639 this.softRefreshPage({ focusBlockId: result.block_id }) 640 return 641 } 642 643 const rowsContainer = blockEl.querySelector('[data-platform-block-target="rows"]') 644 if (!rowsContainer) return 645 646 const rowTemplate = blockEl.querySelector('template[data-platform-block-target="rowTemplate"]') 647 const items = result.items || [] 648 649 // Ensure the rows container has the right number of <row> elements. 650 let rowEls = Array.from(rowsContainer.querySelectorAll('[data-platform-block-target="row"]')) 651 while (rowEls.length < items.length && rowTemplate) { 652 const clone = rowTemplate.content.firstElementChild.cloneNode(true) 653 rowsContainer.appendChild(clone) 654 rowEls = Array.from(rowsContainer.querySelectorAll('[data-platform-block-target="row"]')) 655 } 656 while (rowEls.length > items.length) { 657 const last = rowEls.pop() 658 last.remove() 659 } 660 661 // Write each item's field values into its row's inputs.
662 rowEls.forEach((rowEl, i) => { 663 const item = items[i] || {} 664 rowEl.querySelectorAll("[data-field]").forEach((input) => { 665 const key = input.dataset.field 666 const newValue = item[key] != null ? String(item[key]) : "" 667 if (input.value !== newValue) { 668 input.value = newValue 669 input.dispatchEvent(new Event("input", { bubbles: true })) 670 input.dispatchEvent(new Event("change", { bubbles: true })) 671 } 672 }) 673 }) 674 675 // Update the item count badge in the header. 676 const countEl = blockEl.querySelector(".block-drag-handle")?.parentElement?.querySelector("span:last-of-type") 677 // The schema is: "â®â® <type> <count>". The count is the *third* span. 678 const headerSpans = blockEl.querySelectorAll("button[data-action=\"platform-block#toggle\"] > span") 679 if (headerSpans.length >= 3) { 680 headerSpans[2].textContent = String(items.length) 681 } 682 683 // Reveal the body so the user can see the change, even if the 684 // block was previously collapsed. 685 this.expandBlock(blockEl) 686 687 // Flash-highlight the row that the AI just changed (if known). 688 if (result.item_index) { 689 const targetRow = rowEls[result.item_index - 1] 690 if (targetRow) { 691 targetRow.scrollIntoView({ behavior: "smooth", block: "center" }) 692 flashHighlight(targetRow) 693 } 694 } else { 695 // No specific row â highlight the whole block. 696 flashHighlight(blockEl) 697 } 698 } 699 700 expandBlock(blockEl) { 701 const body = blockEl.querySelector('[data-platform-block-target="body"]') 702 if (body) body.classList.remove("hidden") 703 } 704 705 // Ask the existing platform-editor controller to repaint the right 706 // pane. It listens for `input` events on its root element, then POSTs 707 // the inspector form to /preview and swaps the turbo-frame, so a 708 // synthetic input event makes it pick up our surgical row updates 709 // exactly like a user keystroke would. 710 requestPreviewUpdate() { 711 const editorEl = document.querySelector('[data-controller~="platform-editor"]') 712 if (!editorEl) return 713 editorEl.dispatchEvent(new Event("input", { bubbles: true })) 714 } 715 716 sendToolResult(callId, result) { 717 if (!this.dc || this.dc.readyState !== "open") return 718 this.dc.send(JSON.stringify({ 719 type: "conversation.item.create", 720 item: { 721 type: "function_call_output", 722 call_id: callId, 723 output: typeof result === "string" ? result : JSON.stringify(result) 724 } 725 })) 726 // If a realtime response is currently being generated, the tool 727 // output is auto-folded into it â DON'T fire another `response.create` 728 // (OpenAI rejects with conversation_already_has_active_response). 729 // For parallel tool calls inside one response, we'll dispatch a single 730 // `response.create` when the active response finishes (see 731 // response.done handler). 732 if (this.activeResponseId) { 733 this.pendingResponseCreate = true 734 } else { 735 this.dc.send(JSON.stringify({type: "response.create"})) 736 } 737 } 738 739 // Inject a user-role message into the live conversation and prompt the 740 // model to respond. Used when the user picks a product from the HUD â 741 // it reads as if they'd said "I picked X" out loud. Same active-response 742 // guard as sendToolResult so we never trip 743 // conversation_already_has_active_response. 744 sendUserMessage(text) { 745 if (!this.dc || this.dc.readyState !== "open") return false 746 this.dc.send(JSON.stringify({ 747 type: "conversation.item.create", 748 item: { 749 type: "message", 750 role: "user", 751 content: [{type: "input_text", text: text}] 752 } 753 })) 754 if (this.activeResponseId) { 755 this.pendingResponseCreate = true 756 } else { 757 this.dc.send(JSON.stringify({type: "response.create"})) 758 } 759 return true 760 } 761 762 // ââ Resource picker MODAL ââââââââââââââââââââââââââââââââââââââââ 763 // 764 // A centered, full-screen-backdrop modal listing matches the AI 765 // returned (products, collections, suppliers, â¦). Two flavours: 766 // ⢠single-tap â a tool returns `picker: true`. Tapping a row 767 // feeds that one resource's handle straight back into the 768 // conversation as if the user had said "I picked X". 769 // ⢠multi-select â `picker: true, picker_mode: "multi"`. Each row 770 // gets a checkbox and a footer with "Add selected (N)"; on 771 // confirm we feed back ALL the ticked items at once. 772 // 773 // The modal supports: 774 // ⢠text search (filters the in-memory list client-side) 775 // ⢠â/â arrow keys to focus rows, Enter to pick/toggle 776 // ⢠Space to toggle (multi mode) 777 // ⢠Escape (or backdrop click) to cancel 778 // 779 // The `kind` opt ("product" | "collection" | "supplier") tunes which 780 // fields show in each row â products lead with stock + price, while 781 // collections/suppliers favour descriptive meta over imagery and use 782 // a stacked single-column layout. 783 784 // items: array â see CatalogueTools for the row shape per kind. 785 // opts: 786 // query? â what the user originally said (becomes the subtitle) 787 // multi? â true for tickbox-style picker 788 // prompt? â one-line instruction overrides the default title 789 // kind? â "product" (default), "collection", "supplier" 790 // context? â opaque hash echoed back to the AI when the user picks 791 // (e.g. {product_handle: "raspberry-pi-5"} for the 792 // "suggest collections for THIS product" flow) 793 showPicker(items, opts = {}) { 794 if (!this.hasPickerTarget || !this.hasPickerListTarget) return 795 const o = (typeof opts === "string") ? { query: opts } : (opts || {}) 796 const list = (items || []).slice(0, 100) 797 if (!list.length) return 798 799 const multi = !!o.multi
800 const kind = o.kind || "product" 801 802 this.pickerMulti = multi 803 this.pickerKind = kind 804 this.pickerSelection = new Set() 805 this.pickerItems = list 806 this.pickerContext = o.context || null 807 this.pickerFocusIndex = 0 808 809 // Stacked single-column layout reads better for collection / 810 // supplier rows (more meta, less imagery to grid against). 811 this.pickerListTarget.classList.toggle("voice-modal-list-stacked", kind !== "product") 812 813 // Title + subtitle. 814 const prompt = (o.prompt && String(o.prompt).trim()) || "" 815 const noun = pluralizeKind(kind, list.length) 816 if (this.hasPickerTitleTarget) { 817 this.pickerTitleTarget.textContent = prompt || (multi 818 ? `Tick the ${noun} you want` 819 : `Pick a ${singularizeKind(kind)}`) 820 } 821 if (this.hasPickerSubtitleTarget) { 822 const queryBit = o.query ? `"${truncateForSpeech(o.query)}"` : "" 823 const sub = [] 824 if (queryBit) sub.push(queryBit) 825 sub.push(`${list.length} ${noun}`) 826 this.pickerSubtitleTarget.textContent = sub.join(" · ") 827 } 828 this.pickerTarget.setAttribute("aria-label", multi 829 ? `${capitalize(noun)} â pick one or more` 830 : `${capitalize(noun)} â pick one`) 831 832 // Renderer per kind. Each row gets a deterministic key so the 833 // search filter can match against a single string fast. 834 this.pickerListTarget.replaceChildren(...list.map((it, idx) => this.buildPickerRow(it, idx, kind, multi))) 835 this.updatePickerCount(list.length, list.length) 836 837 // Pre-select items flagged by the server (e.g. AI-suggested 838 // collections â they start ticked, the user untICKs the ones they 839 // disagree with rather than ticking the ones they want). 840 if (multi) { 841 list.forEach((it, idx) => { 842 if (!it || !it._preselect) return 843 const row = this.pickerListTarget.querySelector(`.voice-modal-item[data-picker-index="${idx}"]`) 844 if (row) this.togglePickerRow(row, it) 845 }) 846 } 847 848 // Footer with Cancel + (multi only) "Add selected (N)". The 849 // single-mode footer also serves as a keyboard hint reminder. 850 if (this.hasPickerFooterTarget) { 851 this.pickerFooterTarget.classList.remove("hidden") 852 if (this.hasPickerConfirmTarget) { 853 this.pickerConfirmTarget.style.display = multi ? "" : "none" 854 this.pickerConfirmTarget.disabled = true 855 this.pickerConfirmTarget.textContent = "Add selected" 856 } 857 if (this.hasPickerHintTarget) { 858 this.pickerHintTarget.textContent = multi 859 ? "ââ navigate · Space / Enter toggles · Esc to close" 860 : "ââ navigate · Enter to pick · Esc to close" 861 } 862 } 863 864 // Reset search box (carrying it over from a previous picker would 865 // surprise the user). 866 if (this.hasPickerSearchTarget) { 867 this.pickerSearchTarget.value = "" 868 } 869 870 // Reveal modal + backdrop. 871 this.pickerTarget.classList.remove("hidden") 872 if (this.hasPickerBackdropTarget) this.pickerBackdropTarget.classList.remove("hidden") 873 874 // Wire up keyboard nav. Stored on `this` so we can detach in closePicker. 875 this.boundPickerKeydown ||= (e) => this.handlePickerKeydown(e) 876 document.addEventListener("keydown", this.boundPickerKeydown) 877 878 // Focus the search input so the user can type immediately. Defer 879 // a tick because the modal is animating in. 880 setTimeout(() => { 881 if (this.hasPickerSearchTarget) this.pickerSearchTarget.focus() 882 this.setFocusedRow(0) 883 }, 30) 884 } 885 886 buildPickerRow(item, index, kind, multi) { 887 const btn = document.createElement("button") 888 btn.type = "button" 889 btn.className = "voice-modal-item" 890 btn.dataset.pickerIndex = String(index) 891 btn.dataset.pickerKey = pickerSearchKey(item, kind) 892 btn.setAttribute("role", multi ? "option" : "button") 893 if (multi) btn.setAttribute("aria-pressed", "false") 894 const itemTitle = item.title || item.handle || item.slug || "" 895 btn.title = multi 896 ? `Toggle "${itemTitle}" in your selection` 897 : `Pick "${itemTitle}"` 898 899 // Faux checkbox for multi-select. Always present in the DOM so the 900 // layout doesn't shift when a row toggles. 901 if (multi) { 902 const box = document.createElement("span") 903 box.className = "voice-modal-check" 904 box.setAttribute("aria-hidden", "true") 905 btn.appendChild(box) 906 } 907 908 // Thumbnail â real image if we have one, fall back to placeholder. 909 let thumb 910 if (item.image_url) { 911 thumb = document.createElement("img") 912 thumb.className = "voice-modal-thumb" 913 thumb.src = item.image_url 914 thumb.alt = "" 915 thumb.loading = "lazy"
916 thumb.addEventListener("error", () => thumb.replaceWith(placeholderModalThumb(kind))) 917 } else { 918 thumb = placeholderModalThumb(kind) 919 } 920 btn.appendChild(thumb) 921 922 // Body â title + meta + (optionally) extra description. 923 const body = document.createElement("div") 924 body.className = "voice-modal-body" 925 926 const titleEl = document.createElement("div") 927 titleEl.className = "voice-modal-item-title" 928 titleEl.textContent = itemTitle 929 body.appendChild(titleEl) 930 931 const metaText = pickerRowMeta(item, kind) 932 if (metaText) { 933 const meta = document.createElement("div") 934 meta.className = "voice-modal-item-meta" 935 if (kind === "product") { 936 const metaBits = [item.vendor, item.product_type].filter((s) => s && String(s).trim()) 937 if (metaBits.length) meta.append(document.createTextNode(metaBits.join(" · "))) 938 const skuText = pickerSkuLabel(item) 939 if (skuText) { 940 if (meta.childNodes.length) meta.append(document.createTextNode(" · ")) 941 const skuEl = document.createElement("span") 942 skuEl.className = "voice-modal-sku" 943 skuEl.textContent = skuText 944 meta.appendChild(skuEl) 945 } 946 if (!meta.childNodes.length) meta.textContent = item.handle || "" 947 } else { 948 meta.textContent = metaText 949 } 950 body.appendChild(meta) 951 } 952 953 const extraText = pickerRowExtra(item, kind) 954 if (extraText) { 955 const extra = document.createElement("div") 956 extra.className = "voice-modal-item-extra" 957 extra.textContent = extraText 958 body.appendChild(extra) 959 } 960 btn.appendChild(body) 961 962 // Side: price + stock badge for products, count/lead-time chip for 963 // collections/suppliers, nothing for things with no useful side info. 964 const side = pickerRowSide(item, kind) 965 if (side) btn.appendChild(side) 966 967 btn.addEventListener("click", () => { 968 if (multi) this.togglePickerRow(btn, item) 969 else this.pickItem(item) 970 }) 971 btn.addEventListener("mousemove", () => { 972 // Track hover as focus too so arrow-key nav + mouse work together. 973 const idx = Number(btn.dataset.pickerIndex) 974 if (!Number.isNaN(idx) && idx !== this.pickerFocusIndex) this.setFocusedRow(idx, false) 975 }) 976 return btn 977 } 978 979 // Toggle one row's membership in the multi-select set + repaint its checkbox. 980 togglePickerRow(rowEl, item) { 981 if (!this.pickerSelection) this.pickerSelection = new Set() 982 const nowSelected = !this.pickerSelection.has(item) 983 if (nowSelected) this.pickerSelection.add(item) 984 else this.pickerSelection.delete(item) 985 986 rowEl.setAttribute("aria-pressed", nowSelected ? "true" : "false") 987 const box = rowEl.querySelector(".voice-modal-check") 988 if (box) box.textContent = nowSelected ? "â" : "" 989 this.updatePickerFooter() 990 } 991 992 updatePickerFooter() { 993 if (!this.hasPickerConfirmTarget) return 994 const n = this.pickerSelection ? this.pickerSelection.size : 0 995 this.pickerConfirmTarget.disabled = n === 0 996 this.pickerConfirmTarget.textContent = n ? `Add selected (${n})` : "Add selected" 997 } 998 999 updatePickerCount(visible, total) { 1000 if (!this.hasPickerCountTarget) return 1001 if (visible === total) { 1002 this.pickerCountTarget.textContent = `${total}` 1003 } else { 1004 this.pickerCountTarget.textContent = `${visible} of ${total}` 1005 } 1006 } 1007 1008 // Text-search filter â applied client-side over `dataset.pickerKey` 1009 // which we built at row-create time. No re-fetch, no server round 1010 // trip; the AI already gave us the candidates. 1011 filterPicker() { 1012 if (!this.hasPickerSearchTarget || !this.hasPickerListTarget) return 1013 const q = this.pickerSearchTarget.value.trim().toLowerCase() 1014 const rows = Array.from(this.pickerListTarget.querySelectorAll(".voice-modal-item")) 1015 let visible = 0 1016 rows.forEach((row) => { 1017 const hit = !q || (row.dataset.pickerKey || "").includes(q) 1018 row.style.display = hit ? "" : "none" 1019 if (hit) visible++ 1020 }) 1021 this.updatePickerCount(visible, rows.length) 1022 1023 // Empty-state â single span injected into the list so it scrolls 1024 // with the (now-empty) grid. 1025 let empty = this.pickerListTarget.querySelector(".voice-modal-empty") 1026 if (!visible) { 1027 if (!empty) { 1028 empty = document.createElement("div") 1029 empty.className = "voice-modal-empty" 1030 this.pickerListTarget.appendChild(empty) 1031 } 1032 empty.textContent = `No matches for "${this.pickerSearchTarget.value}".` 1033 } else if (empty) { 1034 empty.remove() 1035 } 1036 1037 // Snap focus to the first visible row (cheap; matches what users expect). 1038 const firstVisible = rows.findIndex((row) => row.style.display !== "none") 1039 if (firstVisible >= 0) this.setFocusedRow(firstVisible, false) 1040 } 1041 1042 // Keyboard nav while a row is focused. Search input has its own 1043 // separate handler (pickerSearchKeydown) for first-row jump-out. 1044 handlePickerKeydown(e) { 1045 if (e.key === "Escape") { this.closePicker(); return } 1046 const focusInSearch = document.activeElement === this.pickerSearchTarget 1047 const rows = this.visiblePickerRows() 1048 if (!rows.length) return 1049 1050 if (e.key === "ArrowDown") { 1051 e.preventDefault() 1052 this.setFocusedRow(this.nextVisibleIndex(1))
1053 if (focusInSearch) this.focusedPickerRow()?.focus() 1054 } else if (e.key === "ArrowUp") { 1055 e.preventDefault() 1056 this.setFocusedRow(this.nextVisibleIndex(-1)) 1057 if (focusInSearch) this.focusedPickerRow()?.focus() 1058 } else if (e.key === "Home" && !focusInSearch) { 1059 e.preventDefault() 1060 this.setFocusedRow(Number(rows[0].dataset.pickerIndex)) 1061 this.focusedPickerRow()?.focus() 1062 } else if (e.key === "End" && !focusInSearch) { 1063 e.preventDefault() 1064 this.setFocusedRow(Number(rows[rows.length - 1].dataset.pickerIndex)) 1065 this.focusedPickerRow()?.focus() 1066 } else if (e.key === "Enter" && !focusInSearch) { 1067 e.preventDefault() 1068 const item = this.pickerItems[this.pickerFocusIndex] 1069 if (!item) return 1070 if (this.pickerMulti) { 1071 const row = this.focusedPickerRow() 1072 if (row) this.togglePickerRow(row, item) 1073 } else { 1074 this.pickItem(item) 1075 } 1076 } else if (e.key === "Enter" && focusInSearch) { 1077 // Search box Enter: in single mode picks the first visible row, 1078 // in multi mode toggles + leaves focus on search so the user can 1079 // keep typing. 1080 e.preventDefault() 1081 const item = this.pickerItems[this.pickerFocusIndex] 1082 if (!item) return 1083 if (this.pickerMulti) { 1084 const row = this.focusedPickerRow() 1085 if (row) this.togglePickerRow(row, item) 1086 } else { 1087 this.pickItem(item) 1088 } 1089 } else if (e.key === " " && this.pickerMulti && !focusInSearch) { 1090 e.preventDefault() 1091 const item = this.pickerItems[this.pickerFocusIndex] 1092 const row = this.focusedPickerRow() 1093 if (item && row) this.togglePickerRow(row, item) 1094 } 1095 } 1096 1097 // Allow â inside the search input to jump focus into the list. Stop 1098 // propagation so the document-level handlePickerKeydown listener 1099 // doesn't also advance the focus index a second time. 1100 pickerSearchKeydown(e) { 1101 if (e.key === "ArrowDown") { 1102 e.preventDefault() 1103 e.stopPropagation() 1104 this.focusedPickerRow()?.focus() 1105 } 1106 } 1107 1108 // Clicking the backdrop = cancel. 1109 pickerBackdropClick() { 1110 this.closePicker() 1111 } 1112 1113 visiblePickerRows() { 1114 if (!this.hasPickerListTarget) return [] 1115 return Array.from(this.pickerListTarget.querySelectorAll(".voice-modal-item")) 1116 .filter((row) => row.style.display !== "none") 1117 } 1118 1119 focusedPickerRow() { 1120 if (!this.hasPickerListTarget) return null 1121 return this.pickerListTarget.querySelector(`.voice-modal-item[data-picker-index="${this.pickerFocusIndex}"]`) 1122 } 1123 1124 setFocusedRow(idx, scroll = true) { 1125 if (!this.hasPickerListTarget) return 1126 this.pickerListTarget.querySelectorAll(".voice-modal-item-focus").forEach((el) => 1127 el.classList.remove("voice-modal-item-focus") 1128 ) 1129 const row = this.pickerListTarget.querySelector(`.voice-modal-item[data-picker-index="${idx}"]`) 1130 if (!row) return 1131 this.pickerFocusIndex = idx 1132 row.classList.add("voice-modal-item-focus") 1133 if (scroll) row.scrollIntoView({ block: "nearest", inline: "nearest" }) 1134 } 1135 1136 nextVisibleIndex(step) { 1137 const rows = this.visiblePickerRows() 1138 if (!rows.length) return this.pickerFocusIndex 1139 const indices = rows.map((r) => Number(r.dataset.pickerIndex)) 1140 const current = indices.indexOf(this.pickerFocusIndex) 1141 if (current === -1) return indices[step > 0 ? 0 : indices.length - 1] 1142 const next = current + step 1143 if (next < 0) return indices[0] 1144 if (next >= indices.length) return indices[indices.length - 1] 1145 return indices[next] 1146 } 1147 1148 // Hand back ONE picked resource (single-tap picker) â feeds its 1149 // identifier back into the conversation as if the user had said "I 1150 // picked X". Generalised across products / collections / suppliers. 1151 pickItem(item) { 1152 const kind = this.pickerKind || "product" 1153 const ctx = this.pickerContext 1154 this.closePicker() 1155 if (!item) return 1156 1157 if (!this.dc || this.dc.readyState !== "open") { 1158 this.appendTranscript("system", "Voice session isn't active â tap the mic to restart, then try again.") 1159 return 1160 } 1161 if (this.aiSpeaking && this.activeResponseId) {
1162 try { this.dc.send(JSON.stringify({type: "response.cancel"})) } catch (_) { /* ignore */ } 1163 this.aiSpeaking = false 1164 } 1165 1166 const summary = pickerItemSummary(item, kind) 1167 this.appendTranscript("user", `Picked: ${summary.short}`) 1168 const contextSuffix = ctx ? ` (context: ${JSON.stringify(ctx)})` : "" 1169 this.sendUserMessage(`I picked ${summary.descriptor}: ${summary.long}${contextSuffix}.`) 1170 this.setStatus(`Picked ${summary.short}`) 1171 } 1172 1173 // Back-compat alias â old code path called pickProduct(); some callers 1174 // (tool result handlers, tests) may still reach for the original name. 1175 pickProduct(product) { this.pickItem(product) } 1176 1177 // Hand back ALL the ticked items (multi-select picker) as one 1178 // follow-up message listing each. 1179 confirmPickerSelection() { 1180 const picked = this.pickerSelection ? Array.from(this.pickerSelection) : [] 1181 const kind = this.pickerKind || "product" 1182 const ctx = this.pickerContext 1183 this.closePicker() 1184 if (!picked.length) return 1185 1186 if (!this.dc || this.dc.readyState !== "open") { 1187 this.appendTranscript("system", "Voice session isn't active â tap the mic to restart, then try again.") 1188 return 1189 } 1190 if (this.aiSpeaking && this.activeResponseId) { 1191 try { this.dc.send(JSON.stringify({type: "response.cancel"})) } catch (_) { /* ignore */ } 1192 this.aiSpeaking = false 1193 } 1194 1195 const noun = pluralizeKind(kind, picked.length) 1196 const titles = picked.map((p) => pickerItemSummary(p, kind).short) 1197 this.appendTranscript("user", `Picked ${picked.length}: ${titles.join(", ")}`) 1198 const lines = picked.map((p) => pickerItemSummary(p, kind).long) 1199 const contextSuffix = ctx ? ` (context: ${JSON.stringify(ctx)})` : "" 1200 this.sendUserMessage(`I picked ${picked.length} ${noun} from the picker: ${lines.join("; ")}${contextSuffix}. Go ahead with these â use the identifiers exactly.`) 1201 this.setStatus(`Picked ${picked.length} ${noun}`) 1202 } 1203 1204 closePicker() { 1205 if (this.boundPickerKeydown) document.removeEventListener("keydown", this.boundPickerKeydown) 1206 this.pickerSelection = null 1207 this.pickerMulti = false 1208 this.pickerKind = null 1209 this.pickerItems = null 1210 this.pickerContext = null 1211 this.pickerFocusIndex = 0 1212 if (this.hasPickerTarget) this.pickerTarget.classList.add("hidden") 1213 if (this.hasPickerBackdropTarget) this.pickerBackdropTarget.classList.add("hidden") 1214 if (this.hasPickerListTarget) this.pickerListTarget.replaceChildren() 1215 if (this.hasPickerFooterTarget) this.pickerFooterTarget.classList.add("hidden") 1216 if (this.hasPickerSearchTarget) this.pickerSearchTarget.value = "" 1217 if (this.hasPickerCountTarget) this.pickerCountTarget.textContent = "" 1218 if (this.hasPickerSubtitleTarget) this.pickerSubtitleTarget.textContent = "" 1219 } 1220 1221 // ââ Text composer âââââââââââââââââââââââââââââââââââââââââââââ 1222 // 1223 // The voice panel's composer lets the user TYPE to the AI instead of 1224 // (or alongside) speaking. Useful in quiet offices, when the user 1225 // doesn't want voice transcription noise, or when they want to paste 1226 // an exact handle/SKU/URL. Same conversation, either modality. 1227 1228 submitComposer(event) { 1229 if (event) event.preventDefault() 1230 if (!this.hasComposerInputTarget) return 1231 const text = this.composerInputTarget.value 1232 if (!text || !text.trim()) return 1233 1234 if (!this.dc || this.dc.readyState !== "open") { 1235 // Composer should still work â kick off a fresh session, but the 1236 // SDP handshake is async. Tell the user to retry once we're up. 1237 this.appendTranscript("system", "Voice session isn't connected yet. Connecting now â try sending again in a moment.") 1238 this.start() 1239 return 1240 } 1241 1242 // Barge in: if the AI is talking, interrupt so it actually reads 1243 // the typed message instead of finishing its previous sentence. 1244 if (this.aiSpeaking && this.activeResponseId) { 1245 try { this.dc.send(JSON.stringify({type: "response.cancel"})) } catch (_) { /* ignore */ } 1246 this.aiSpeaking = false 1247 } 1248 1249 this.appendTranscript("user", text) 1250 this.sendUserMessage(text) 1251 this.composerInputTarget.value = "" 1252 this.composerResize() 1253 } 1254 1255 composerKeydown(event) { 1256 // Enter sends, Shift+Enter inserts a newline. Standard chat UX. 1257 if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { 1258 event.preventDefault() 1259 this.submitComposer() 1260 } 1261 } 1262 1263 // Auto-grow the textarea (capped by CSS max-height). 1264 composerResize() { 1265 if (!this.hasComposerInputTarget) return 1266 const el = this.composerInputTarget 1267 el.style.height = "auto" 1268 el.style.height = Math.min(el.scrollHeight, 112) + "px" // 7rem â 112px 1269 } 1270 1271 // Client-only tool handlers. Anything DOM-y goes here so we don't 1272 // round-trip the server for a 1-line action. Server-resident tools 1273 // fall through to the default fetch path. 1274 get clientHandlers() { 1275 return { 1276 save_form: ({ form_id } = {}) => { 1277 const form = form_id ? document.getElementById(form_id) : document.getElementById("platform-form") 1278 if (!form) return { ok: false, error: "No form on this page to save." } 1279 if (typeof form.requestSubmit === "function") form.requestSubmit() 1280 else form.submit() 1281 return { ok: true, markdown: "Saving the page now." } 1282 }, 1283 scroll_to_block: ({ block_id }) => { 1284 const el = document.querySelector(`#platform-blocks [data-block-id="${block_id}"]`) 1285 if (!el) return { ok: false, error: "Couldn't find that block on screen." } 1286 el.scrollIntoView({ behavior: "smooth", block: "center" }) 1287 flashHighlight(el) 1288 return { ok: true, markdown: "Scrolled to it." } 1289 }, 1290 hide_block_ui: ({ block_id }) => { 1291 const el = document.querySelector(`#platform-blocks [data-block-id="${block_id}"]`) 1292 if (!el) return { ok: false, error: "Couldn't find that block on screen." } 1293 el.style.display = "none" 1294 return { ok: true, markdown: "Hidden from the editor (not saved)." } 1295 }, 1296 show_block_ui: ({ block_id }) => { 1297 const el = document.querySelector(`#platform-blocks [data-block-id="${block_id}"]`) 1298 if (!el) return { ok: false, error: "Couldn't find that block on screen." } 1299 el.style.display = "" 1300 return { ok: true, markdown: "Visible again." } 1301 }, 1302 1303 // ââ form__* â auto-discovered form interaction ââ 1304 // Every visible <form> on the admin page becomes addressable by 1305 // its id (or a generated data-voice-form-id). Field setters 1306 // dispatch input + change events so Turbo / Stimulus controllers 1307 // pick up the value as if the user had typed it. 1308 form__list: () => { 1309 const forms = collectVisibleForms() 1310 if (forms.length === 0) return { ok: true, forms: [], markdown: "No forms on this page." } 1311 const lines = forms.map((f, i) => `${i + 1}. ${f.label} â id=${f.form_id} (${f.fields.length} fields)`) 1312 return { 1313 ok: true, 1314 forms: forms.map((f) => ({ 1315 form_id: f.form_id, 1316 label: f.label, 1317 action: f.action, 1318 field_count: f.fields.length 1319 })), 1320 markdown: `Forms on this page:\n${lines.join("\n")}` 1321 } 1322 }, 1323 form__describe: ({ form_id } = {}) => { 1324 const formEl = findFormById(form_id) 1325 if (!formEl) return { ok: false, error: `No form with id "${form_id}" on this page.` } 1326 const fields = collectFormFields(formEl) 1327 const label = inferFormLabel(formEl)
1328 const fieldLines = fields.map((f) => { 1329 const labelBit = f.label && f.label !== f.name ? ` â ${f.label}` : "" 1330 let valueBit = "" 1331 if (f.value !== "" && f.value != null && !(Array.isArray(f.value) && f.value.length === 0)) { 1332 const truncated = JSON.stringify(f.value).slice(0, 80) 1333 valueBit = ` â current: ${truncated}` 1334 } 1335 let optionsBit = "" 1336 if (f.type === "select" || f.type === "select-multiple") { 1337 const opts = (f.options || []).slice(0, 8).map((o) => o.value).join(", ") 1338 optionsBit = ` â options: [${opts}${f.options && f.options.length > 8 ? ", â¦" : ""}]` 1339 } else if (f.type === "radio") { 1340 optionsBit = ` â options: [${(f.options || []).join(", ")}]` 1341 } 1342 return `- ${f.name} (${f.type})${labelBit}${optionsBit}${valueBit}` 1343 }) 1344 return { 1345 ok: true, 1346 form_id: stableFormId(formEl, 0), 1347 label, 1348 action: formEl.getAttribute("action") || "", 1349 method: (formEl.getAttribute("method") || "post").toLowerCase(), 1350 fields, 1351 markdown: `Fields on "${label}":\n${fieldLines.join("\n")}` 1352 } 1353 }, 1354 form__set_field: ({ form_id, field, value } = {}) => { 1355 const formEl = findFormById(form_id) 1356 if (!formEl) return { ok: false, error: `No form with id "${form_id}".` } 1357 if (field == null || field === "") return { ok: false, error: "Need a `field` name (or label) to set." } 1358 const result = setFormFieldValue(formEl, field, value) 1359 if (!result.ok) return result 1360 const friendly = result.label || result.name 1361 return { 1362 ok: true, 1363 form_id, 1364 name: result.name, 1365 label: result.label, 1366 value: result.value, 1367 markdown: `Set ${friendly} to ${truncateForSpeech(result.value)}.` 1368 } 1369 }, 1370 form__set_fields: ({ form_id, values } = {}) => { 1371 const formEl = findFormById(form_id) 1372 if (!formEl) return { ok: false, error: `No form with id "${form_id}".` } 1373 if (!values || typeof values !== "object" || Array.isArray(values)) { 1374 return { ok: false, error: "`values` must be an object of field-name â value." } 1375 } 1376 const set = [] 1377 const failed = [] 1378 for (const [field, value] of Object.entries(values)) { 1379 const r = setFormFieldValue(formEl, field, value) 1380 if (r.ok) set.push({ name: r.name, label: r.label, value: r.value }) 1381 else failed.push({ field, error: r.error }) 1382 } 1383 const md = [] 1384 if (set.length) { 1385 const names = set.map((s) => s.label || s.name).join(", ") 1386 md.push(`Set ${set.length} field${set.length === 1 ? "" : "s"}: ${names}.`) 1387 } 1388 if (failed.length) { 1389 const reasons = failed.map((f) => `${f.field} (${f.error})`).join("; ") 1390 md.push(`Couldn't set: ${reasons}.`) 1391 } 1392 return { 1393 ok: set.length > 0, 1394 form_id, set, failed, 1395 markdown: md.join(" ") || "No changes made." 1396 } 1397 }, 1398 form__submit: ({ form_id } = {}) => { 1399 const formEl = findFormById(form_id) 1400 if (!formEl) return { ok: false, error: `No form with id "${form_id}".` } 1401 if (typeof formEl.requestSubmit === "function") formEl.requestSubmit() 1402 else formEl.submit() 1403 return { ok: true, form_id, markdown: `Submitting "${inferFormLabel(formEl)}".` } 1404 } 1405 } 1406 } 1407 1408 // ââ Screen context ââââââââââââââââââââââââââââââââââââââââââââ 1409 1410 // Collect a small snapshot of what the user is currently looking at â 1411 // the AI uses this to resolve "this", "here", "the third block". 1412 collectScreenContext() { 1413 const ctx = { 1414 page: document.title, 1415 url: window.location.pathname 1416 } 1417 1418 // Platform admin page â the inspector form lives at #platform-form 1419 // and the URL is /admin/platforms/:handle/edit (Platform overrides 1420 // to_param to return its handle, so the route segment is a string, 1421 // not a numeric id). 1422 const platformForm = document.getElementById("platform-form") 1423 if (platformForm) { 1424 const action = platformForm.getAttribute("action") || "" 1425 const m = action.match(/\/admin\/platforms\/([^/?#]+)/) 1426 if (m) { 1427 const segment = decodeURIComponent(m[1]) 1428 if (/^\d+$/.test(segment)) { 1429 ctx.platform_id = Number(segment) 1430 } else { 1431 ctx.platform_handle = segment 1432 } 1433 const nameInput = platformForm.querySelector('[name="ecommerce_platform[n
1433ame]"]') 1434 const handleInput = platformForm.querySelector('[name="ecommerce_platform[handle]"]') 1435 if (nameInput) ctx.platform_name = nameInput.value 1436 // Form-supplied handle wins (it may have been edited). 1437 if (handleInput && handleInput.value) ctx.platform_handle = handleInput.value 1438 } 1439 } 1440 1441 // Collection admin page â /admin/collections/:id (numeric id). 1442 // Pull it from the URL so CollectionHeroTools sees the active 1443 // collection without the AI having to pass it in every call. 1444 const cm = window.location.pathname.match(/\/admin\/collections\/(\d+)/) 1445 if (cm) { 1446 ctx.collection_id = Number(cm[1]) 1447 const titleEl = document.querySelector("h1, h2") // best-effort 1448 if (titleEl) ctx.collection_title = titleEl.textContent.trim() 1449 } 1450 1451 // Product admin page â /admin/products/:id (numeric id). 1452 const pm = window.location.pathname.match(/\/admin\/products\/(\d+)/) 1453 if (pm) ctx.product_id = Number(pm[1]) 1454 1455 // Blog article admin page â /admin/blogs/:blog_id/articles/:article_id(/edit). 1456 // "new" isn't numeric so it won't match. Lets ArticleProductsTools see the 1457 // active article without the AI passing it in every call. 1458 const am = window.location.pathname.match(/\/admin\/blogs\/(\d+)\/articles\/(\d+)/) 1459 if (am) { 1460 ctx.blog_id = Number(am[1]) 1461 ctx.article_id = Number(am[2]) 1462 } 1463 1464 // List visible blocks so the AI can match positions by content. 1465 const blockEls = document.querySelectorAll('#platform-blocks [data-block-id]') 1466 if (blockEls.length) { 1467 ctx.visible_blocks = Array.from(blockEls).map((el, i) => ({ 1468 position: i + 1, 1469 id: Number(el.dataset.blockId), 1470 type: el.dataset.blockType 1471 })) 1472 } 1473 1474 // Auto-discover every visible form on the page so the AI can fill 1475 // them by voice without us hand-coding one tool per controller. 1476 // collectVisibleForms is idempotent â it lazily assigns 1477 // data-voice-form-id so subsequent calls return the same ids. 1478 try { 1479 ctx.forms = collectVisibleForms() 1480 } catch (err) { 1481 console.warn("[admin-voice] form collection failed:", err) 1482 ctx.forms = [] 1483 } 1484 1485 return ctx 1486 } 1487 1488 // ââ Visual state ââââââââââââââââââââââââââââââââââââââââââââ 1489 1490 setState(state, label) { 1491 this.state = state 1492 if (SESSION) SESSION.state = state 1493 this.setStatus(label) 1494 this.renderState() 1495 } 1496 1497 setStatus(label) { 1498 if (this.hasStatusTarget && label != null) this.statusTarget.textContent = label 1499 } 1500 1501 renderState() { 1502 if (!this.hasButtonTarget) return 1503 const btn = this.buttonTarget 1504 btn.classList.remove( 1505 "voice-idle", "voice-connecting", "voice-listening", "voice-hearing", 1506 "voice-thinking", "voice-speaking", "voice-tool", "voice-error" 1507 ) 1508 btn.classList.add(`voice-${this.state}`) 1509 btn.setAttribute("data-state", this.state) 1510 btn.setAttribute("aria-label", labelForState(this.state)) 1511 btn.setAttribute("title", labelForState(this.state)) 1512 } 1513 1514 // ââ Transcript panel ââââââââââââââââââââââââââââââââââââââââââââ 1515 1516 appendTranscript(role, text) { 1517 if (!this.hasTranscriptTarget) return 1518 const row = document.createElement("div") 1519 row.className = `voice-row voice-row-${role}` 1520 row.textContent = text 1521 this.transcriptTarget.appendChild(row) 1522 this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight 1523 } 1524 1525 appendTranscriptDelta(delta) { 1526 if (!this.hasTranscriptTarget || !delta) return 1527 if (!this.currentAssistantRow) { 1528 this.currentAssistantRow = document.createElement("div") 1529 this.currentAssistantRow.className = "voice-row voice-row-assistant" 1530 this.transcriptTarget.appendChild(this.currentAssistantRow) 1531 } 1532 this.currentAssistantRow.textContent += delta 1533 this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight 1534 } 1535 1536 finalizeAssistantTranscript() { 1537 this.currentAssistantRow = null 1538 } 1539 1540 appendUserTranscriptDelta(delta) { 1541 if (!this.hasTranscriptTarget || !delta) return 1542 if (!this.currentUserRow) { 1543 this.currentUserRow = document.createElement("div") 1544 this.currentUserRow.className = "voice-row voice-row-user" 1545 this.transcriptTarget.appendChild(this.currentUserRow) 1546 } 1547 this.currentUserRow.textContent += delta 1548 this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight 1549 } 1550 1551 finalizeUserTranscript(full) { 1552 if (this.currentUserRow && full) this.currentUserRow.textContent = full 1553 this.currentUserRow = null 1554 } 1555 1556 // ââ Tool-call logging ââââââââââââââââââââââââââââââââââââââââââ 1557 // 1558 // Every AI tool invocation gets two transcript rows: one when the 1559 // call goes out (name + a short args preview) and one when it returns 1560 // (ok / error + a short result preview). The on-screen text is kept 1561 // compact; the full arguments / result JSON is stashed on the row's 1562 // dataset so the "Copy" button can include the complete input and 1563 // output in the clipboard dump (handy for bug reports). 1564 1565 appendToolCall(name, args) { 1566 const hasArgs = args && typeof args === "object" && Object.keys(args).length > 0 1567 const preview = hasArgs ? ` ${summarizeForTranscript(args, 220)}` : "" 1568 const full = hasArgs 1569 ? `â tool call: ${name}\n args: ${stringifyForClipboard(args)}` 1570 : `â tool call: ${name}()` 1571 this.appendToolRow("voice-row-tool-call", `â ${name}${preview}`, full) 1572 } 1573 1574 appendToolResult(name, result) { 1575 const failed = result && typeof result === "object" && result.ok === false 1576 const cls = failed ? "voice-row-tool-result voice-row-tool-error" : "voice-row-tool-result" 1577 let preview 1578 if (failed) { 1579 preview = `error: ${result.error || "failed"}` 1580 } else if (result && typeof result === "object") { 1581 preview = result.markdown || result.copy || result.error || "" 1582 if (!preview) preview = summarizeForTranscript(result, 240) 1583 } else if (result != null) { 1584 preview = String(result) 1585 } else { 1586 preview = "(no result)" 1587 } 1588 const full = `â result (${name}): ${stringifyForClipboard(result)}` 1589 this.appendToolRow(cls, `â ${truncateForTranscript(preview, 240)}`, full) 1590 } 1591 1592 appendToolRow(extraClass, displayText, copyText) { 1593 if (!this.hasTranscriptTarget) return
1594 const row = document.createElement("div") 1595 row.className = `voice-row voice-row-tool ${extraClass}` 1596 row.textContent = displayText 1597 if (copyText) row.dataset.copyText = copyText 1598 this.transcriptTarget.appendChild(row) 1599 this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight 1600 } 1601 1602 togglePanel() { 1603 if (this.hasPanelTarget) this.panelTarget.classList.toggle("hidden") 1604 } 1605 1606 // Copy the whole conversation to the clipboard as plain text â useful 1607 // for pasting the log into a bug report or chat. Tries the async 1608 // Clipboard API first; falls back to a hidden-textarea execCommand 1609 // for insecure contexts where it's unavailable. 1610 async copyTranscript() { 1611 const text = this.transcriptText() 1612 if (!text) { this.flashCopyLabel("Nothing yet"); return } 1613 let copied = false 1614 try { 1615 if (navigator.clipboard?.writeText) { 1616 await navigator.clipboard.writeText(text) 1617 copied = true 1618 } 1619 } catch (_) { /* fall through to the legacy path */ } 1620 if (!copied) copied = this.legacyCopy(text) 1621 this.flashCopyLabel(copied ? "Copied!" : "Copy failed") 1622 } 1623 1624 // Flatten the transcript panel into "Speaker: text" lines. Keeps the 1625 // system breadcrumbs ("Connectingâ¦", errors, "Session ended.") since 1626 // they're handy when handing the log to a developer, and the tool 1627 // rows â for those it uses the full args/result JSON stashed on 1628 // dataset.copyText rather than the truncated on-screen preview. 1629 // Prepends a tiny header with the page URL + time so the log is 1630 // self-describing. 1631 transcriptText() { 1632 if (!this.hasTranscriptTarget) return "" 1633 const lines = [] 1634 this.transcriptTarget.querySelectorAll(".voice-row").forEach((row) => { 1635 const display = (row.textContent || "").trim() 1636 const text = (row.dataset.copyText || display).trim() 1637 if (!text) return 1638 if (row.classList.contains("voice-row-user")) lines.push(`You: ${text}`) 1639 else if (row.classList.contains("voice-row-assistant")) lines.push(`Assistant: ${text}`) 1640 else if (row.classList.contains("voice-row-tool")) lines.push(text) 1641 else lines.push(`â ${text}`) 1642 }) 1643 if (!lines.length) return "" 1644 return [ 1645 "Voice assistant conversation", 1646 window.location.href, 1647 new Date().toString(), 1648 "", 1649 ...lines, 1650 "" 1651 ].join("\n") 1652 } 1653 1654 legacyCopy(text) { 1655 try { 1656 const ta = document.createElement("textarea") 1657 ta.value = text 1658 ta.setAttribute("readonly", "") 1659 ta.style.position = "fixed" 1660 ta.style.top = "-1000px" 1661 ta.style.opacity = "0" 1662 document.body.appendChild(ta) 1663 ta.select() 1664 const ok = document.execCommand("copy") 1665 ta.remove() 1666 return ok 1667 } catch (_) { 1668 return false 1669 } 1670 } 1671 1672 // Brief inline confirmation on the Copy button itself, then revert. 1673 flashCopyLabel(msg) { 1674 if (!this.hasCopyButtonTarget) return 1675 const btn = this.copyButtonTarget 1676 btn.textContent = msg 1677 btn.disabled = true 1678 clearTimeout(this._copyLabelTimer) 1679 this._copyLabelTimer = setTimeout(() => { 1680 btn.textContent = "Copy" 1681 btn.disabled = false 1682 }, 1400) 1683 } 1684 1685 // ââ CSRF & soft refresh ââââââââââââââââââââââââââââââââââââââââââââ 1686 1687 csrfToken() { 1688 return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "" 1689 } 1690 1691 async refreshCsrfToken() { 1692 try { 1693 const res = await fetch(this.csrfUrlValue, { 1694 method: "GET", 1695 credentials: "same-origin", 1696 headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" } 1697 }) 1698 if (!res.ok) return this.csrfToken() 1699 const { token } = await res.json() 1700 if (token) { 1701 const meta = document.querySelector('meta[name="csrf-token"]') 1702 if (meta) meta.setAttribute("content", token) 1703 return token 1704 } 1705 } catch (_) { /* fall through */ } 1706 return this.csrfToken() 1707 } 1708 1709 // Re-fetch the current page and morph the inspector + preview turbo 1710 // frame back into the DOM so AI edits become visible without dropping 1711 // the voice session or losing scroll. 1712 // 1713 // Preserves which block bodies were expanded across the refresh and 1714 // re-opens the block the AI just touched. 1715 async softRefreshPage({ focusBlockId, focusItemIndex, deletedBlockId } = {}) { 1716 try { 1717 const expandedIds = new Set() 1718 document.querySelectorAll('#platform-blocks [data-block-id]').forEach((el) => { 1719 const body = el.querySelector('[data-platform-block-target="body"]') 1720 if (body && !body.classList.contains("hidden")) { 1721 expandedIds.add(el.dataset.blockId) 1722 } 1723 }) 1724 if (focusBlockId != null) expandedIds.add(String(focusBlockId)) 1725 if (deletedBlockId != null) expandedIds.delete(String(deletedBlockId)) 1726 1727 const res = await fetch(window.location.href, { 1728 credentials: "same-origin", 1729 headers: { "Accept": "text/html", "X-Requested-With": "XMLHttpRequest" } 1730 }) 1731 if (!res.ok) return 1732 const html = await res.text() 1733 const doc = new DOMParser().parseFromString(html, "text/html") 1734 1735 let didSwap = false 1736 1737 // Platform editor pages: swap the blocks list + the live-preview frame. 1738 const newBlocks = doc.getElementById("platform-blocks") 1739 const curBlocks = document.getElementById("platform-blocks") 1740 if (newBlocks && curBlocks) { 1741 curBlocks.replaceChildren(...newBlocks.childNodes) 1742 didSwap = true 1743 } 1744 const newPreview = doc.getElementById("platform_preview") 1745 const curPreview = document.getElementById("platform_preview") 1746 if (newPreview && curPreview) { 1747 curPreview.replaceChildren(...newPreview.childNodes) 1748 didSwap = true 1749 } 1750 1751 // Generic opt-in refresh regions (e.g. the collection page's hero + 1752 // products cards): any element tagged `data-voice-refresh` has its 1753 // children replaced from the freshly fetched HTML, matched by id. 1754 // Stimulus re-binds controllers on the new nodes automatically. 1755 document.querySelectorAll("[data-voice-refresh][id]").forEach((cur) => { 1756 const fresh = doc.getElementById(cur.id) 1757 if (!fresh) return 1758 cur.replaceChildren(...fresh.childNodes) 1759 didSwap = true 1760 }) 1761 1762 // Nothing on this page knows how to refresh surgically â fall back to 1763 // a Turbo visit so the AI's change still shows up. The voice agent 1764 // wrapper is data-turbo-permanent, so the session survives; the 1765 // turbo:load handler re-pushes screen context. 1766 if (!didSwap) { 1767 if (window.Turbo && typeof window.Turbo.visit === "function") { 1768 window.Turbo.visit(window.location.href, { action: "replace" }) 1769 } else { 1770 window.location.reload() 1771 } 1772 return 1773 } 1774 1775 // Re-expand the blocks that were open before the refresh. 1776 expandedIds.forEach((id) => { 1777 const el = document.querySelector(`#platform-blocks [data-block-id="${id}"]`) 1778 if (el) this.expandBlock(el) 1779 }) 1780 1781 // Scroll to + highlight the AI's focus block / row. 1782 if (focusBlockId != null) { 1783 const targetBlock = document.querySelector( 1784 `#platform-blocks [data-block-id="${focusBlockId}"]` 1785 ) 1786 if (targetBlock) { 1787 this.expandBlock(targetBlock) 1788 if (focusItemIndex) { 1789 const rows = targetBlock.querySelectorAll('[data-platform-block-target="row"]')
1790 const row = rows[focusItemIndex - 1] 1791 if (row) { 1792 row.scrollIntoView({ behavior: "smooth", block: "center" }) 1793 flashHighlight(row) 1794 } 1795 } else { 1796 targetBlock.scrollIntoView({ behavior: "smooth", block: "center" }) 1797 flashHighlight(targetBlock) 1798 } 1799 } 1800 } 1801 1802 // Update screen context (e.g. new block ids) for subsequent tools. 1803 this.screen = this.collectScreenContext() 1804 } catch (err) { 1805 console.warn("[admin-voice] soft refresh failed:", err) 1806 } 1807 } 1808} 1809 1810function labelForState(s) { 1811 switch (s) { 1812 case "idle": return "Tap to talk" 1813 case "connecting": return "Connectingâ¦" 1814 case "listening": return "Listening" 1815 case "hearing": return "Hearing you" 1816 case "thinking": return "Thinkingâ¦" 1817 case "speaking": return "Speakingâ¦" 1818 case "tool": return "Workingâ¦" 1819 case "error": return "Error â tap to retry" 1820 default: return "" 1821 } 1822} 1823 1824function humanizeName(name) { 1825 if (!name) return "something" 1826 return String(name).replace(/^platform_blocks__/, "").replace(/_/g, " ") 1827} 1828 1829// ââ Tool-row text helpers ââ 1830// One-line, whitespace-collapsed preview of a value for the transcript 1831// panel (object â compact JSON, string â the string), truncated. 1832function summarizeForTranscript(value, max = 200) { 1833 if (value == null) return "" 1834 let s 1835 if (typeof value === "string") { 1836 s = value 1837 } else { 1838 try { s = JSON.stringify(value) } catch (_) { s = String(value) } 1839 } 1840 return truncateForTranscript(s, max) 1841} 1842 1843function truncateForTranscript(text, max = 200) { 1844 const s = String(text == null ? "" : text).replace(/\s+/g, " ").trim() 1845 return s.length > max ? `${s.slice(0, max - 1)}â¦` : s 1846} 1847 1848// Pretty JSON for the clipboard dump â keeps the FULL args / result so a 1849// pasted log carries the complete tool input and output. Strings pass 1850// through verbatim. Best-effort: never throws. 1851function stringifyForClipboard(value) { 1852 if (value == null) return "null" 1853 if (typeof value === "string") return value 1854 try { return JSON.stringify(value, null, 2) } catch (_) { return String(value) } 1855} 1856 1857function flashHighlight(el) { 1858 const prev = el.style.boxShadow 1859 el.style.transition = "box-shadow 200ms ease-out" 1860 el.style.boxShadow = "0 0 0 3px #6366f1" 1861 setTimeout(() => { el.style.boxShadow = prev }, 1500) 1862} 1863 1864// Brief text dump of the current screen for injection into the 1865// realtime conversation. Keep this short â every system message 1866// counts against context budget. 1867function formatScreenContext(screen) { 1868 if (!screen) return "[screen context] (none)" 1869 const parts = [] 1870 parts.push(`url=${screen.url || "?"}`) 1871 if (screen.page) parts.push(`title=${screen.page}`) 1872 if (screen.platform_id) parts.push(`platform_id=${screen.platform_id}`) 1873 if (screen.platform_handle) parts.push(`platform_handle=${screen.platform_handle}`) 1874 if (screen.platform_name) parts.push(`platform_name=${screen.platform_name}`) 1875 if (screen.collection_id) parts.push(`collection_id=${screen.collection_id}`) 1876 if (screen.collection_title) parts.push(`collection_title=${screen.collection_title}`) 1877 if (screen.product_id) parts.push(`product_id=${screen.product_id}`) 1878 if (Array.isArray(screen.visible_blocks) && screen.visible_blocks.length) { 1879 const blocks = screen.visible_blocks 1880 .map((b) => `${b.position}.${b.type}#${b.id}`) 1881 .join(", ") 1882 parts.push(`blocks=[${blocks}]`) 1883 } 1884 if (Array.isArray(screen.forms) && screen.forms.length) { 1885 const formLines = screen.forms.map((f) => { 1886 // Cap each form at 25 fields in the context dump â long settings 1887 // forms can have 50+ inputs. The AI can call form__describe to 1888 // see the full list when it needs to. 1889 const shown = (f.fields || []).slice(0, 25) 1890 const more = (f.fields?.length || 0) > shown.length 1891 ? ` (+${(f.fields.length - shown.length)} more â call form__describe to see all)` : "" 1892 const fieldStr = shown.map((field) => { 1893 const val = field.value 1894 let valBit = "" 1895 if (val !== "" && val != null && !(Array.isArray(val) && val.length === 0)) { 1896 const s = typeof val === "string" ? val.slice(0, 50) : JSON.stringify(val).slice(0, 50) 1897 valBit = `=${JSON.stringify(s)}` 1898 } 1899 return `${field.name}(${field.type})${valBit}` 1900 }).join(", ") 1901 return ` ⢠id=${f.form_id} "${f.label}": ${fieldStr}${more}` 1902 }).join("\n") 1903 parts.push(`forms (${screen.forms.length}):\n${formLines}`) 1904 } 1905 return `[screen context] ${parts.join(" ")}` 1906} 1907 1908// ââ Form discovery + manipulation helpers ââ 1909// 1910// These run purely in the browser. The matching server-side tool spec 1911// lives in app/services/admin_ai/form_tools.rb so the realtime model 1912// learns about form__list / form__describe / form__set_field / 1913// form__set_fields / form__submit when the session is created â but 1914// every invocation lands in clientHandlers above and routes here. 1915 1916// Walk the DOM for every form the user can interact with right now. 1917// Skips forms inside our own voice overlay, hidden forms, and forms 1918// with no editable fields (e.g. logout buttons whose only input is a 1919// hidden _method). 1920function collectVisibleForms() { 1921 const out = [] 1922 const formEls = Array.from(document.querySelectorAll("form")) 1923 for (const formEl of formEls) { 1924 if (formEl.closest("#admin-voice-agent")) continue 1925 if (!isFormUserVisible(formEl)) continue 1926 const fields = collectFormFields(formEl) 1927 if (fields.length === 0) continue 1928 const formId = stableFormId(formEl, out.length) 1929 out.push({ 1930 form_id: formId, 1931 label: inferFormLabel(formEl), 1932 action: formEl.getAttribute("action") || "", 1933 method: (formEl.getAttribute("method") || "post").toLowerCase(), 1934 fields 1935 }) 1936 } 1937 return out 1938} 1939 1940function isFormUserVisible(formEl) { 1941 if (formEl.hidden) return false 1942 const rect = formEl.getBoundingClientRect() 1943 if (rect.width === 0 && rect.height === 0) return false 1944 let cur = formEl 1945 while (cur && cur !== document.documentElement) { 1946 const cs = window.getComputedStyle(cur) 1947 if (cs.display === "none" || cs.visibility === "hidden") return false 1948 cur = cur.parentElement 1949 } 1950 return true 1951} 1952
1953// Stable per-page id: prefer the form's own id, then any previously 1954// assigned data-voice-form-id, otherwise mint one (cached on the 1955// element so subsequent scans return the same value). 1956function stableFormId(formEl, index) { 1957 if (formEl.id) return formEl.id 1958 if (formEl.dataset.voiceFormId) return formEl.dataset.voiceFormId 1959 const action = (formEl.getAttribute("action") || "form") 1960 .replace(/[^a-z0-9-]/gi, "-") 1961 .replace(/-+/g, "-") 1962 .replace(/^-|-$/g, "") 1963 .slice(-40) 1964 const generated = `voice-form-${index + 1}${action ? `--${action}` : ""}` 1965 formEl.dataset.voiceFormId = generated 1966 return generated 1967} 1968 1969function inferFormLabel(formEl) { 1970 // Heading-based: most admin forms sit under an <h1>. Search the 1971 // ancestor with a heading so we associate "New Article" with the 1972 // article form rather than picking the global page title. 1973 const main = formEl.closest("main") || document.body 1974 const heading = main.querySelector("h1, h2") 1975 if (heading?.textContent?.trim()) return heading.textContent.trim().replace(/\s+/g, " ") 1976 return formEl.getAttribute("aria-label") 1977 || formEl.id 1978 || (formEl.getAttribute("action") || "Form") 1979} 1980 1981// Editable fields only â skip hidden/file/password/submit/CSRF and 1982// anything inside <template> tags. 1983function collectFormFields(formEl) { 1984 const seenRadioGroups = new Set() 1985 const fields = [] 1986 const elements = Array.from(formEl.querySelectorAll("input, textarea, select")) 1987 for (const el of elements) { 1988 const desc = describeFormField(el, seenRadioGroups, formEl) 1989 if (desc) fields.push(desc) 1990 } 1991 return fields 1992} 1993 1994function describeFormField(el, seenRadioGroups, formEl) { 1995 if (el.closest("template")) return null 1996 if (el.closest("#admin-voice-agent")) return null 1997 const tag = el.tagName.toLowerCase() 1998 const type = (el.type || tag).toLowerCase() 1999 const name = el.name || el.id 2000 if (!name) return null 2001 if (["hidden", "file", "password", "submit", "button", "reset", "image"].includes(type)) return null 2002 if (["authenticity_token", "_method", "utf8"].includes(name)) return null 2003 2004 const label = labelForFormField(el) 2005 2006 if (type === "radio") { 2007 if (seenRadioGroups.has(name)) return null 2008 seenRadioGroups.add(name) 2009 const group = Array.from(formEl.querySelectorAll(`input[type="radio"][name="${cssEscape(name)}"]`)) 2010 return { 2011 name, type: "radio", label, 2012 options: group.map((r) => r.value), 2013 value: group.find((r) => r.checked)?.value || "" 2014 } 2015 } 2016 if (tag === "select") { 2017 return { 2018 name, 2019 type: el.multiple ? "select-multiple" : "select", 2020 label, 2021 options: Array.from(el.options).map((o) => ({ value: o.value, text: (o.text || "").trim() })), 2022 value: el.multiple 2023 ? Array.from(el.selectedOptions).map((o) => o.value) 2024 : el.value 2025 } 2026 } 2027 if (type === "checkbox") { 2028 return { name, type: "checkbox", label, value: !!el.checked } 2029 } 2030 // text, number, email, url, search, tel, date, time, datetime-local, 2031 // week, month, color, range, textarea â all settable as a string. 2032 return { name, type, label, value: el.value || "" } 2033} 2034 2035function labelForFormField(el) { 2036 if (el.id) { 2037 const lab = document.querySelector(`label[for="${cssEscape(el.id)}"]`) 2038 if (lab) return lab.textContent.trim().replace(/\s+/g, " ") 2039 } 2040 const wrap = el.closest("label") 2041 if (wrap) { 2042 const clone = wrap.cloneNode(true) 2043 clone.querySelectorAll("input, select, textarea").forEach((c) => c.remove()) 2044 return clone.textContent.trim().replace(/\s+/g, " ") 2045 } 2046 return el.placeholder || el.getAttribute("aria-label") || el.name || "" 2047} 2048 2049function findFormById(formId) { 2050 if (!formId) return null 2051 const safe = String(formId) 2052 const escaped = cssEscape(safe) 2053 // 1. Native id match 2054 let formEl = document.getElementById(safe) 2055 if (formEl && formEl.tagName.toLowerCase() === "form") return formEl 2056 // 2. Generated voice id 2057 formEl = document.querySelector(`form[data-voice-form-id="${escaped}"]`) 2058 if (formEl) return formEl 2059 // 3. Re-scan (lazily assigns voice ids), then retry. Useful when the 2060 // AI calls form__set_field on a form it heard about in the screen 2061 // context dump but whose id hasn't been minted in a fresh DOM. 2062 collectVisibleForms() 2063 return document.querySelector(`form[data-voice-form-id="${escaped}"]`) || null 2064} 2065 2066function findFormField(formEl, fieldName) { 2067 if (!formEl || fieldName == null) return null 2068 const target = String(fieldName).trim() 2069 if (!target) return null 2070 const escaped = cssEscape(target) 2071 // 1. Exact name attribute 2072 let el = formEl.querySelector(`[name="${escaped}"]`) 2073 if (el) return el 2074 // 2. id attribute 2075 el = formEl.querySelector(`#${escaped}`) 2076 if (el && ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) return el 2077 // 3. Label match (case-insensitive, exact then contains) 2078 const lower = target.toLowerCase() 2079 const labels = Array.from(formEl.querySelectorAll("label")) 2080 for (const lab of labels) { 2081 if (lab.textContent.trim().toLowerCase() === lower) { 2082 const found = labelTargetField(lab, formEl) 2083 if (found) return found 2084 } 2085 } 2086 for (const lab of labels) { 2087 if (lab.textContent.trim().toLowerCase().includes(lower)) { 2088 const found = labelTargetField(lab, formEl) 2089 if (found) return found 2090 } 2091 } 2092 return null 2093} 2094 2095function labelTargetField(lab, formEl) { 2096 if (lab.htmlFor) { 2097 const f = formEl.querySelector(`#${cssEscape(lab.htmlFor)}`) 2098 if (f) return f 2099 } 2100 return lab.querySelector("input, textarea, select") 2101} 2102 2103// Set a field's value AND fire input/change so Stimulus controllers, 2104// Turbo morphing, and any inline validation see it as a real edit. 2105function setFormFieldValue(formEl, fieldName, value) { 2106 const el = findFormField(formEl, fieldName) 2107 if (!el) return { ok: false, error: `No field "${fieldName}" on this form.` } 2108 const tag = el.tagName.toLowerCase() 2109 const type = (el.type || tag).toLowerCase() 2110 const label = labelForFormField(el) 2111 2112 if (type === "radio") { 2113 const radios = formEl.querySelectorAll(`input[type="radio"][name="${cssEscape(el.name)}"]`) 2114 const target = Array.from(radios).find((r) => String(r.value) === String(value)) 2115 if (!target) return { ok: false, error: `No radio with value "${value}" in group "${el.name}".` } 2116 target.checked = true 2117 fireInputAndChange(target) 2118 flashFormField(target) 2119 return { ok: true, name: el.name, label, value: target.value } 2120 } 2121 2122 if (type === "checkbox") { 2123 const truthy = (value === true || value === "true" || value === 1 || value === "1" || value === "on" || value === "yes") 2124 el.checked = truthy 2125 fireInputAndChange(el) 2126 flashFormField(el) 2127 return { ok: true, name: el.name, label, value: el.checked } 2128 } 2129 2130 if (tag === "select") { 2131 if (el.multiple && Array.isArray(value)) { 2132 const wanted = value.map(String)
2133 Array.from(el.options).forEach((o) => { o.selected = wanted.includes(o.value) }) 2134 } else { 2135 el.value = String(value == null ? "" : value) 2136 } 2137 fireInputAndChange(el) 2138 flashFormField(el) 2139 return { 2140 ok: true, name: el.name, label, 2141 value: el.multiple ? Array.from(el.selectedOptions).map((o) => o.value) : el.value 2142 } 2143 } 2144 2145 // Text-like input or textarea. 2146 const newVal = (value == null) ? "" : String(value) 2147 // Use the native value setter so frameworks that watch the property 2148 // descriptor (React etc.) and any setters added by Trix/CodeMirror 2149 // pick up the change. 2150 const proto = tag === "textarea" 2151 ? window.HTMLTextAreaElement.prototype 2152 : window.HTMLInputElement.prototype 2153 const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set 2154 if (setter) setter.call(el, newVal); else el.value = newVal 2155 fireInputAndChange(el) 2156 flashFormField(el) 2157 return { ok: true, name: el.name, label, value: el.value } 2158} 2159 2160function fireInputAndChange(el) { 2161 el.dispatchEvent(new Event("input", { bubbles: true })) 2162 el.dispatchEvent(new Event("change", { bubbles: true })) 2163} 2164 2165function flashFormField(el) { 2166 // Visual feedback so the user can see exactly what the AI changed. 2167 // Falls back gracefully if the element doesn't accept inline style. 2168 try { 2169 const prev = el.style.boxShadow 2170 const prevTransition = el.style.transition 2171 el.style.transition = "box-shadow 200ms ease-out" 2172 el.style.boxShadow = "0 0 0 3px rgba(99, 102, 241, 0.55)" 2173 setTimeout(() => { 2174 el.style.boxShadow = prev 2175 el.style.transition = prevTransition 2176 }, 1200) 2177 } catch (_) { /* silent */ } 2178} 2179 2180function truncateForSpeech(value) { 2181 if (value == null) return "(empty)" 2182 if (typeof value === "boolean") return value ? "on" : "off" 2183 const s = typeof value === "string" ? value : JSON.stringify(value) 2184 return s.length > 80 ? `${s.slice(0, 77)}â¦` : s 2185} 2186 2187function cssEscape(s) { 2188 if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(String(s)) 2189 return String(s).replace(/[^\w-]/g, "\\$&") 2190} 2191 2192// ââ Product picker rendering helpers ââ 2193 2194// Larger placeholder glyph for the modal â kind-tuned so collections 2195// get a folder-y mark, suppliers a building-y one, products the image 2196// placeholder (which is what users already recognise from admin). 2197function placeholderModalThumb(kind) { 2198 const wrap = document.createElement("div") 2199 wrap.className = "voice-modal-thumb" 2200 let svg 2201 if (kind === "collection") { 2202 svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/></svg>' 2203 } else if (kind === "supplier") { 2204 svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"/></svg>' 2205 } else { 2206 svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>' 2207 } 2208 wrap.innerHTML = svg 2209 return wrap 2210} 2211 2212// "SKU PI5-8GB" or "SKU PI5-8GB +2 more" for multi-variant products. 2213function pickerSkuLabel(product) {
2214 const skus = Array.isArray(product.skus) ? product.skus : (product.sku ? [product.sku] : []) 2215 if (!skus.length) return "" 2216 const extra = skus.length > 1 ? ` +${skus.length - 1} more` : "" 2217 return `SKU ${skus[0]}${extra}` 2218} 2219 2220// Colour-coded stock pill. Mirrors CatalogueTools#stock_label server-side. 2221function pickerQtyBadge(product) { 2222 if (!product.tracked) return {cls: "voice-modal-qty-na", text: "Untracked"} 2223 const local = Number(product.available || 0) 2224 if (local > 0) return {cls: local <= 5 ? "voice-modal-qty-low" : "voice-modal-qty-in", text: `${local} in stock`} 2225 if (product.always_available) return {cls: "voice-modal-qty-in", text: "In stock"} 2226 const supplier = Number(product.supplier_available || 0) 2227 if (supplier > 0) return {cls: "voice-modal-qty-low", text: `${supplier} at supplier`} 2228 return {cls: "voice-modal-qty-out", text: "Out of stock"} 2229} 2230 2231function formatPrice(value) { 2232 const n = Number(value) 2233 if (!isFinite(n)) return "" 2234 return `$${n.toFixed(2)}` 2235} 2236 2237// ââ Picker row helpers (per-kind formatting) âââââââââââââââââââââ 2238// 2239// Each kind has its own idea of what "the headline meta" and "side info" 2240// look like â products lead with stock/price, collections with how many 2241// products they hold, suppliers with website + lead time. Centralising 2242// the dispatch here keeps buildPickerRow tidy. 2243 2244function singularizeKind(kind) { 2245 switch (kind) { 2246 case "collection": return "collection" 2247 case "supplier": return "supplier" 2248 default: return "product" 2249 } 2250} 2251 2252function pluralizeKind(kind, n) { 2253 const base = singularizeKind(kind) 2254 return n === 1 ? base : (base + "s") 2255} 2256 2257function capitalize(s) { 2258 if (!s) return "" 2259 return s.charAt(0).toUpperCase() + s.slice(1) 2260} 2261 2262// One precomputed lowercase string per row that filterPicker matches 2263// the search box against. Pulling all the searchable fields into a 2264// single haystack means a tight loop with `.includes()` filters 2265// hundreds of rows without breaking a sweat. 2266function pickerSearchKey(item, kind) { 2267 const bits = [item.title, item.handle, item.slug] 2268 if (kind === "product") { 2269 bits.push(item.vendor, item.product_type, item.sku) 2270 if (Array.isArray(item.skus)) bits.push(...item.skus) 2271 } else if (kind === "collection") { 2272 bits.push(item.parent_title, item.parent_handle, item.description) 2273 } else if (kind === "supplier") { 2274 bits.push(item.website_url, item.contact_email, item.domain) 2275 } 2276 return bits.filter((s) => s != null && s !== "").join(" ").toLowerCase() 2277} 2278 2279// First-line meta â the "vendor · type · SKU" bit under the title. 2280// (Used as a presence-check by buildPickerRow; products are special-cased 2281// there because they get inline DOM nodes for the SKU monospace span.) 2282function pickerRowMeta(item, kind) { 2283 if (kind === "product") { 2284 const bits = [item.vendor, item.product_type, pickerSkuLabel(item)].filter((s) => s && String(s).trim()) 2285 return bits.join(" · ") || item.handle || "" 2286 } 2287 if (kind === "collection") { 2288 const bits = [] 2289 if (item.parent_title) bits.push(`under ${item.parent_title}`) 2290 if (typeof item.product_count === "number") { 2291 bits.push(`${item.product_count} ${item.product_count === 1 ? "product" : "products"}`) 2292 } 2293 if (item.handle) bits.push(`/collections/${item.handle}`) 2294 return bits.join(" · ") 2295 } 2296 if (kind === "supplier") { 2297 const bits = [] 2298 if (item.product_count != null) bits.push(`${item.product_count} ${item.product_count === 1 ? "product" : "products"}`) 2299 if (item.dispatch_lead_time_max) { 2300 const min = item.dispatch_lead_time_min 2301 const max = item.dispatch_lead_time_max 2302 const range = (min && min !== max) ? `${min}â${max}` : `${max}` 2303 bits.push(`dispatch ${range} ${max === 1 ? "day" : "days"}`) 2304 } 2305 if (item.website_url) bits.push(item.website_url.replace(/^https?:\/\//, "")) 2306 return bits.join(" · ") || item.slug || "" 2307 } 2308 return "" 2309} 2310 2311// Optional second line under the meta â e.g. a curation guide preview 2312// for a collection, or a contact email for a supplier. Truncated by 2313// CSS line-clamp. 2314function pickerRowExtra(item, kind) { 2315 if (kind === "collection") { 2316 return (item.description || item.curation_guide || "").toString().trim() 2317 } 2318 if (kind === "supplier") { 2319 return (item.contact_email || "").toString().trim() 2320 } 2321 return "" 2322} 2323 2324// Side panel (price / stock pill / count chip) â only products have a 2325// "natural" side panel; collections + suppliers just show a subtle 2326// count badge when one is supplied.
2327function pickerRowSide(item, kind) { 2328 if (kind === "product") { 2329 const side = document.createElement("div") 2330 side.className = "voice-modal-item-side" 2331 if (item.price != null && item.price !== "") { 2332 const price = document.createElement("span") 2333 price.className = "voice-modal-price" 2334 price.textContent = formatPrice(item.price) 2335 side.appendChild(price) 2336 } 2337 const qty = pickerQtyBadge(item) 2338 const qtyEl = document.createElement("span") 2339 qtyEl.className = `voice-modal-qty ${qty.cls}` 2340 qtyEl.textContent = qty.text 2341 side.appendChild(qtyEl) 2342 return side 2343 } 2344 if (kind === "collection" && typeof item.product_count === "number") { 2345 const side = document.createElement("div") 2346 side.className = "voice-modal-item-side" 2347 const chip = document.createElement("span") 2348 chip.className = `voice-modal-qty ${item.product_count > 0 ? "voice-modal-qty-in" : "voice-modal-qty-na"}` 2349 chip.textContent = `${item.product_count}` 2350 side.appendChild(chip) 2351 return side 2352 } 2353 return null 2354} 2355 2356// The string we drop back into the conversation when the user picks 2357// this item. `short` shows in the transcript ("Picked: X"); `long` 2358// goes to the model and includes the handle/slug so it can act 2359// without ambiguity. 2360function pickerItemSummary(item, kind) { 2361 const title = item.title || item.handle || item.slug || "" 2362 if (kind === "collection") { 2363 return { 2364 descriptor: "collection", 2365 short: title, 2366 long: `"${title}" (handle: ${item.handle || ""})` 2367 } 2368 } 2369 if (kind === "supplier") { 2370 const slug = item.slug || item.handle || "" 2371 return { 2372 descriptor: "supplier", 2373 short: title, 2374 long: `"${title}" (slug: ${slug})` 2375 } 2376 } 2377 // Product (default) 2378 const sku = item.sku ? `, SKU: ${item.sku}` : "" 2379 return { 2380 descriptor: "product", 2381 short: title, 2382 long: `"${title}" (handle: ${item.handle || ""}${sku})` 2383 } 2384}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.