Download src/client/rendering/studio.mjs from xlt99/EmboDi: direct link, hf CLI and curl.
- Browser
- Download file 27.4 kB
-
https://huggingface.co/spaces/xlt99/EmboDi/resolve/main/src/client/rendering/studio.mjs
- Command line
-
hf download hf://spaces/xlt99/EmboDi/src/client/rendering/studio.mjs
-
curl -L -o studio.mjs https://huggingface.co/spaces/xlt99/EmboDi/resolve/main/src/client/rendering/studio.mjs
27.4 kB
| import {languagePattern} from "../../shared/language.mjs"; | |
| import { validateVoiceContext, voicePages } from "../../shared/voice-review.mjs"; | |
| import { createSpeechEndpoint } from "../../shared/speech-endpoint.mjs"; | |
| import { uiText } from "../../shared/ui-text.mjs"; | |
| const $ = (id) => document.getElementById(id); | |
| const blobData = (blob) => new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = reject; | |
| reader.readAsDataURL(blob); | |
| }); | |
| function createStudio({ api, getContext, requestJob, handleText = async () => null, canInterrupt = () => false, onVoiceCapture = () => { | |
| }, onVoiceFinish = () => { | |
| }, onReference, onReadout, onLibrary = () => { | |
| }, notify, getScripted = () => null, scriptedMode = false, voiceOnly = false, mediaDevices = globalThis.navigator?.mediaDevices }) { | |
| const conversationId = sessionStorage.getItem("vrbuild-conversation") || crypto.randomUUID(); | |
| sessionStorage.setItem("vrbuild-conversation", conversationId); | |
| let config = null, recorder = null, stream = null, recordTimer = null, recordState = "idle", stopRequested = false, audioPlayer = null, voiceGeneration = 0; | |
| let pendingVoice = null, reviewPage = 0; | |
| let messages = [], library = [], selectionSerial = 0, recordGeneration = 0, transcriptController = null; | |
| let sendSerial = 0, activeSend = null, speech = { status: "idle" }, audioUrl = null, pendingSpeechFinish = null; | |
| const safe = (fn) => async () => { | |
| try { | |
| await fn(); | |
| } catch (error) { | |
| notify(error.message); | |
| } | |
| }; | |
| function draft(value) { | |
| $("edit-prompt").value = value; | |
| if ($("voice-transcript") && value) $("voice-transcript").textContent = "You: " + value; | |
| onReadout({ draft: value }); | |
| } | |
| function renderConversation(entry) { | |
| messages = entry.messages || []; | |
| $("chat-messages").replaceChildren(); | |
| for (const message of messages.slice(-12)) { | |
| const bubble = document.createElement("div"); | |
| bubble.className = "chat-bubble " + message.role; | |
| const label = document.createElement("small"); | |
| label.textContent = message.role === "user" ? "You" : "Agent"; | |
| const text = document.createElement("div"); | |
| text.textContent = message.content; | |
| bubble.append(label, text); | |
| $("chat-messages").append(bubble); | |
| } | |
| $("chat-messages").scrollTop = $("chat-messages").scrollHeight; | |
| $("preferences").textContent = uiText(entry.preferences?.length ? "Saved preferences: " + entry.preferences.join("; ") : ""); | |
| const last = messages.at(-1), userIndex = messages.findLastIndex((m) => m.role === "user"), reply = messages.slice(userIndex + 1).findLast((m) => m.role === "assistant"); | |
| if (last) onReadout({ message: last.content, role: last.role, dialogue: { userText: userIndex >= 0 ? messages[userIndex].content : "", agentText: reply?.content || "" } }); | |
| } | |
| async function say(text) { | |
| if (scriptedMode) return; | |
| cancelSpeech(); | |
| const generation = ++voiceGeneration; | |
| const report = (status) => { | |
| if (generation === voiceGeneration) { | |
| speech = { status, text }; | |
| onReadout({ speech }); | |
| } | |
| }; | |
| if (!config || config.voice.provider === "off" || recordState !== "idle") { | |
| report("skipped"); | |
| return; | |
| } | |
| report("loading"); | |
| const controller = new AbortController(); | |
| let resolveCompletion, settled = false; | |
| const completion = new Promise((resolve) => resolveCompletion = resolve); | |
| const finish = (status) => { | |
| if (settled) return; | |
| settled = true; | |
| clearTimeout(timer); | |
| if (status !== "completed") controller.abort(); | |
| report(status); | |
| if (pendingSpeechFinish === finish) pendingSpeechFinish = null; | |
| resolveCompletion(status); | |
| }; | |
| const timer = setTimeout(() => { | |
| finish("failed"); | |
| globalThis.speechSynthesis?.cancel(); | |
| audioPlayer?.pause(); | |
| notify("Speech timed out. The text reply is still available."); | |
| }, 15e3); | |
| pendingSpeechFinish = finish; | |
| try { | |
| if (config.voice.provider === "browser") { | |
| if (!globalThis.speechSynthesis) { | |
| finish("unavailable"); | |
| return completion; | |
| } | |
| const utterance = new SpeechSynthesisUtterance(text); | |
| const language = /\p{Script=Han}/u.test(text) ? "zh" : "en"; | |
| utterance.lang = config.voice.language?.startsWith(language) ? config.voice.language : language === "zh" ? "zh-CN" : "en-US"; | |
| const voice = speechSynthesis.getVoices().find((v) => v.lang.toLowerCase().startsWith(utterance.lang.toLowerCase().split("-")[0])); | |
| if (voice) utterance.voice = voice; | |
| utterance.onstart = () => report("speaking"); | |
| utterance.onend = () => finish("completed"); | |
| utterance.onerror = () => finish("failed"); | |
| speechSynthesis.speak(utterance); | |
| } else { | |
| const response = await fetch("/api/speech/speak", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: text.slice(0, 1900) }), signal: controller.signal }); | |
| if (!response.ok) throw new Error((await response.json()).error); | |
| const blob = await response.blob(); | |
| if (generation !== voiceGeneration || recordState !== "idle") { | |
| finish("cancelled"); | |
| return completion; | |
| } | |
| const url = URL.createObjectURL(blob); | |
| audioUrl = url; | |
| audioPlayer = new Audio(url); | |
| audioPlayer.onended = () => { | |
| URL.revokeObjectURL(url); | |
| finish("completed"); | |
| }; | |
| audioPlayer.onerror = () => finish("failed"); | |
| await audioPlayer.play(); | |
| report("speaking"); | |
| } | |
| } catch (error) { | |
| if (generation === voiceGeneration && !settled) notify("Text reply retained. Speech did not finish: " + error.message); | |
| finish("failed"); | |
| } | |
| return completion; | |
| } | |
| function cancelSpeech() { | |
| pendingSpeechFinish?.("cancelled"); | |
| voiceGeneration++; | |
| globalThis.speechSynthesis?.cancel(); | |
| audioPlayer?.pause(); | |
| if (audioUrl) { | |
| URL.revokeObjectURL(audioUrl); | |
| audioUrl = null; | |
| } | |
| if (["loading", "speaking"].includes(speech.status)) { | |
| speech = { ...speech, status: "cancelled" }; | |
| onReadout({ speech }); | |
| } | |
| } | |
| async function confirmedSelection() { | |
| if (scriptedMode) return; | |
| const serial = ++selectionSerial, context = getContext(); | |
| if (!context.ids.length) return; | |
| const entry = await api("/api/conversation/select", { conversationId, ...context }); | |
| if (serial !== selectionSerial) return; | |
| renderConversation(entry); | |
| } | |
| async function sendText(value = $("edit-prompt").value, capturedContext = getContext()) { | |
| if (scriptedMode) { | |
| if (languagePattern("studio.cancel").test(value.trim())) { | |
| getScripted()?.cancel(); | |
| return; | |
| } | |
| throw new Error("Hold X to speak, then release"); | |
| } | |
| if (!value.trim()) throw new Error("Enter or speak your request first"); | |
| if (activeSend !== null && !canInterrupt(value)) throw new Error("Processing the previous request. Please wait or say Stop."); | |
| selectionSerial++; | |
| const serial = ++sendSerial; | |
| activeSend = serial; | |
| const replies = []; | |
| let published = false; | |
| const publish = (reply, { speak = true } = {}) => { | |
| if (serial !== sendSerial) return; | |
| if (!/\p{Script=Han}/u.test(value)) reply = uiText(reply); | |
| if (!published) { | |
| messages.push({ role: "user", content: value }); | |
| published = true; | |
| } | |
| replies.push(reply); | |
| messages.push({ role: "assistant", content: reply }); | |
| draft(""); | |
| renderConversation({ messages }); | |
| return speak ? say(reply) : void 0; | |
| }; | |
| if (voiceOnly) onReadout({ message: value, role: "user" }); | |
| try { | |
| const reply = await handleText(value, capturedContext, { publish }); | |
| if (reply !== null) { | |
| if (serial !== sendSerial || reply === void 0) return; | |
| if (typeof reply === "string") publish(reply, { speak: replies.length === 0 }); | |
| if (activeSend === serial) activeSend = null; | |
| if (replies.length) try { | |
| const entry = await api("/api/director-dialogue", { conversationId, text: value, reply: replies.join("\n") }); | |
| if (serial === sendSerial) renderConversation(entry); | |
| } catch { | |
| notify("Text reply retained. Conversation history could not be saved."); | |
| } | |
| return; | |
| } | |
| if (getContext().busy) throw new Error("Finish the current request, voice input, or preview first"); | |
| draft(""); | |
| onReadout({ message: value, role: "user" }); | |
| await requestJob("chat", value, conversationId, capturedContext); | |
| } catch (error) { | |
| if (serial === sendSerial) draft(value); | |
| throw error; | |
| } finally { | |
| if (activeSend === serial) activeSend = null; | |
| } | |
| } | |
| function voiceDraft() { | |
| if (recordState !== "reviewing" || !pendingVoice) return null; | |
| const pages = voicePages(pendingVoice.text); | |
| return { text: pendingVoice.text, targetLabel: pendingVoice.context.targetLabel || "Current scene", page: reviewPage, pages: pages.length, pageText: pages[reviewPage], targetIds: pendingVoice.context.targetIds || [] }; | |
| } | |
| function paintRecording() { | |
| const label = recordState === "recording" ? "■ Stop listening" : recordState === "reviewing" ? "● Speak again" : recordState === "transcribing" ? "Transcribing…" : recordState === "requesting" ? "Waiting for microphone…" : "● Start speaking"; | |
| for (const id of ["voice-input", "voice-talk"]) if ($(id)) { | |
| $(id).textContent = uiText(label); | |
| $(id).disabled = ["requesting", "transcribing"].includes(recordState); | |
| } | |
| if ($("voice-status")) $("voice-status").textContent = uiText(recordState === "reviewing" ? "Review, then A / Enter Send · X Retry · B / Esc Cancel" : recordState === "idle" ? "Click to speak · Quest: hold X, release to review" : recordState === "recording" ? automatic ? "Listening · Review after speaking · B Cancel" : "Listening · Click to stop or release X" : "Processing voice…"); | |
| onReadout({ recording: recordState, autoVoice: automatic, voiceDraft: voiceDraft() }); | |
| } | |
| function pageVoice(step) { | |
| if (!voiceDraft()) return; | |
| reviewPage = Math.max(0, Math.min(voicePages(pendingVoice.text).length - 1, reviewPage + step)); | |
| paintRecording(); | |
| } | |
| async function confirmVoice() { | |
| if (recordState !== "reviewing" || !pendingVoice) return; | |
| const entry = pendingVoice; | |
| validateVoiceContext(entry.context, getContext()); | |
| pendingVoice = null; | |
| recordState = "idle"; | |
| reviewPage = 0; | |
| paintRecording(); | |
| if (entry.destination === "image") { | |
| $("image-generation-prompt").value = entry.text; | |
| return requestJob("image", entry.text, conversationId, entry.context); | |
| } | |
| if (entry.destination === "blueprint") { | |
| $("blueprint-prompt").value = entry.text; | |
| draft(""); | |
| onReadout({ message: entry.text, role: "user" }); | |
| notify("Requirements saved. Click Build after adding your photos."); | |
| return; | |
| } | |
| return sendText(entry.text, entry.context); | |
| } | |
| let autoContext = null, autoTimer = null, automatic = false; | |
| function stopAuto() { | |
| clearInterval(autoTimer); | |
| autoTimer = null; | |
| autoContext?.close().catch(() => { | |
| }); | |
| autoContext = null; | |
| } | |
| async function startVoice({ destination = "agent", auto = false } = {}) { | |
| if (scriptedMode) { | |
| getScripted()?.press(); | |
| return; | |
| } | |
| if (!["idle", "reviewing"].includes(recordState)) return; | |
| const previous = pendingVoice; | |
| const capturedContext = structuredClone(previous?.context || getContext()); | |
| if (previous) { | |
| validateVoiceContext(capturedContext, getContext()); | |
| destination = previous.destination; | |
| } | |
| if (destination === "agent" && voiceOnly && capturedContext.phase === "reference") destination = "blueprint"; | |
| if (destination === "agent" ? capturedContext.voiceBusy ?? capturedContext.busy : capturedContext.busy) throw new Error("Finish the current request, selection, or preview first"); | |
| if (!mediaDevices?.getUserMedia || !globalThis.MediaRecorder) throw new Error("This browser cannot record audio. Open the local page in a browser with microphone support."); | |
| automatic = auto; | |
| onVoiceCapture(capturedContext); | |
| const generation = ++recordGeneration; | |
| stopRequested = false; | |
| recordState = "requesting"; | |
| paintRecording(); | |
| cancelSpeech(); | |
| try { | |
| config = await api("/api/config"); | |
| if (generation !== recordGeneration) return; | |
| if (config.speech.provider === "openai" && !config.speech.hasKey) throw new Error("Add your OpenAI API key under Speech transcription in API settings"); | |
| if (!config.speech.model?.trim()) throw new Error("Open API settings and enter a speech transcription model first"); | |
| if (stopRequested) { | |
| recordState = "idle"; | |
| paintRecording(); | |
| onVoiceFinish(); | |
| return; | |
| } | |
| const activeStream = await mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); | |
| if (generation !== recordGeneration || stopRequested) { | |
| activeStream.getTracks().forEach((t) => t.stop()); | |
| if (generation === recordGeneration) { | |
| recordState = "idle"; | |
| paintRecording(); | |
| onVoiceFinish(); | |
| } | |
| return; | |
| } | |
| stream = activeStream; | |
| const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"].find((t) => MediaRecorder.isTypeSupported(t)); | |
| const activeRecorder = new MediaRecorder(activeStream, mime ? { mimeType: mime } : {}), recordChunks = []; | |
| recorder = activeRecorder; | |
| activeRecorder.ondataavailable = (event) => { | |
| if (event.data.size) recordChunks.push(event.data); | |
| }; | |
| activeRecorder.onerror = () => { | |
| if (generation === recordGeneration) { | |
| cancelVoice(); | |
| if (previous && voiceOnly) { | |
| pendingVoice = previous; | |
| recordState = "reviewing"; | |
| draft(previous.text); | |
| paintRecording(); | |
| } | |
| notify("Audio recording failed. Check microphone permission."); | |
| } | |
| }; | |
| activeRecorder.onstop = async () => { | |
| stopAuto(); | |
| activeStream.getTracks().forEach((t) => t.stop()); | |
| if (generation !== recordGeneration) return; | |
| clearTimeout(recordTimer); | |
| stream = null; | |
| recordState = "transcribing"; | |
| paintRecording(); | |
| const controller = new AbortController(); | |
| transcriptController = controller; | |
| try { | |
| const blob = new Blob(recordChunks, { type: activeRecorder.mimeType || "audio/webm" }), data = await blobData(blob); | |
| const result = await api("/api/speech/transcribe", { audio: data.split(",")[1], mime: blob.type }, { signal: controller.signal }); | |
| if (generation !== recordGeneration) return; | |
| if (!result.text?.trim()) throw Error("No speech recognized. Please speak again."); | |
| draft(result.text); | |
| if (voiceOnly) { | |
| pendingVoice = { text: result.text, context: capturedContext, destination }; | |
| reviewPage = 0; | |
| recordState = "reviewing"; | |
| paintRecording(); | |
| notify("Review transcript · A Send · X Retry · B Cancel"); | |
| return; | |
| } | |
| recordState = "idle"; | |
| paintRecording(); | |
| if (destination === "image") { | |
| $("image-generation-prompt").value = result.text; | |
| await requestJob("image", result.text, conversationId, capturedContext); | |
| } else if (destination === "blueprint") { | |
| $("blueprint-prompt").value = result.text; | |
| onReadout({ message: result.text, role: "user" }); | |
| notify("Requirements saved. Click Build after adding your photos."); | |
| } else await sendText(result.text, capturedContext); | |
| } catch (error) { | |
| if (generation === recordGeneration) notify(error.message); | |
| } finally { | |
| if (generation === recordGeneration) { | |
| onVoiceFinish(); | |
| recordState = pendingVoice ? "reviewing" : "idle"; | |
| paintRecording(); | |
| recorder = null; | |
| transcriptController = null; | |
| } | |
| } | |
| }; | |
| activeRecorder.start(); | |
| recordState = "recording"; | |
| paintRecording(); | |
| recordTimer = setTimeout(stopVoice, 3e4); | |
| if (auto) { | |
| const Audio2 = globalThis.AudioContext || globalThis.webkitAudioContext; | |
| if (!Audio2) throw Error("Automatic speech detection is unavailable in this browser."); | |
| autoContext = new Audio2(); | |
| await autoContext.resume(); | |
| if (generation !== recordGeneration) { | |
| stopAuto(); | |
| return; | |
| } | |
| const analyser = autoContext.createAnalyser(); | |
| analyser.fftSize = 1024; | |
| autoContext.createMediaStreamSource(activeStream).connect(analyser); | |
| const buffer = new Float32Array(analyser.fftSize), endpoint = createSpeechEndpoint(); | |
| autoTimer = setInterval(() => { | |
| if (generation !== recordGeneration) return stopAuto(); | |
| analyser.getFloatTimeDomainData(buffer); | |
| const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length), decision = endpoint.sample(performance.now(), rms); | |
| if (decision === "send") stopVoice(); | |
| if (decision === "cancel") { | |
| cancelVoice(); | |
| notify("No speech detected. Choose Other to try again."); | |
| } | |
| }, 40); | |
| notify(voiceOnly ? "Listening · Review after speaking · B Cancel" : "Listening · Sends after speaking · B Cancel"); | |
| } | |
| } catch (error) { | |
| if (generation !== recordGeneration) return; | |
| cancelVoice(); | |
| if (previous && voiceOnly) { | |
| pendingVoice = previous; | |
| recordState = "reviewing"; | |
| draft(previous.text); | |
| paintRecording(); | |
| } | |
| throw new Error("Voice input unavailable: " + error.message); | |
| } | |
| } | |
| function stopVoice() { | |
| if (scriptedMode) return getScripted()?.release(); | |
| stopRequested = true; | |
| if (recorder?.state === "recording") recorder.stop(); | |
| } | |
| function cancelVoice() { | |
| stopAuto(); | |
| if (scriptedMode) getScripted()?.cancel(); | |
| onVoiceFinish(); | |
| recordGeneration++; | |
| stopRequested = true; | |
| clearTimeout(recordTimer); | |
| transcriptController?.abort(); | |
| transcriptController = null; | |
| if (recorder?.state === "recording") recorder.stop(); | |
| recorder = null; | |
| stream?.getTracks().forEach((t) => t.stop()); | |
| stream = null; | |
| pendingVoice = null; | |
| reviewPage = 0; | |
| recordState = "idle"; | |
| if (voiceOnly) draft(""); | |
| paintRecording(); | |
| cancelSpeech(); | |
| } | |
| const roleLabels = { speech: "Speech transcription", voice: "Spoken replies", analysis: "Conversation and preferences", construction: "3D scene construction", images: "Image generation" }; | |
| const choices = { speech: [["openai", "OpenAI (default)"], ["openai-compatible", "Other compatible API"]], voice: [["browser", "Browser speech"], ["openai", "OpenAI"], ["openai-compatible", "Other compatible API"], ["off", "Off"]], analysis: [["codex", "Local Codex"], ["openai-compatible", "OpenAI-compatible API"]], construction: [["codex", "Local Codex"], ["openai-compatible", "OpenAI-compatible API"]], images: [["disabled", "Disabled"], ["openai-compatible", "OpenAI Images-compatible API"]] }; | |
| function renderConfig() { | |
| $("provider-fields").replaceChildren(); | |
| for (const [role, label] of Object.entries(roleLabels)) { | |
| const fieldset = document.createElement("fieldset"); | |
| fieldset.dataset.role = role; | |
| const legend = document.createElement("legend"); | |
| legend.textContent = uiText(label); | |
| fieldset.append(legend); | |
| const select = document.createElement("select"); | |
| select.name = "provider"; | |
| select.setAttribute("aria-label", uiText(label + " protocol")); | |
| for (const [value, name] of choices[role]) { | |
| const option = document.createElement("option"); | |
| option.value = value; | |
| option.textContent = uiText(name); | |
| select.append(option); | |
| } | |
| select.value = config[role].provider; | |
| fieldset.append(select); | |
| for (const [key, placeholder] of [["baseUrl", "API Base URL"], ["model", "Model name"], ...role === "voice" ? [["voice", "Voice ID, e.g. alloy"]] : [], ...["speech", "voice"].includes(role) ? [["language", "Language, e.g. en-US"]] : [], ["apiKey", config[role].hasKey ? "Key saved; leave blank to keep it" : "API Key"]]) { | |
| const input = document.createElement("input"); | |
| input.name = key; | |
| input.type = key === "apiKey" ? "password" : "text"; | |
| input.placeholder = uiText(placeholder); | |
| input.setAttribute("aria-label", uiText(label + " " + key)); | |
| input.autocomplete = "off"; | |
| input.value = key === "apiKey" ? "" : config[role][key] || ""; | |
| fieldset.append(input); | |
| } | |
| const clearLabel = document.createElement("label"), clear = document.createElement("input"); | |
| clear.type = "checkbox"; | |
| clear.name = "clearKey"; | |
| clearLabel.append(clear, document.createTextNode(uiText("Clear saved key"))); | |
| fieldset.append(clearLabel); | |
| $("provider-fields").append(fieldset); | |
| if (["speech", "voice"].includes(role)) { | |
| const hint = document.createElement("p"); | |
| hint.className = "muted"; | |
| fieldset.append(hint); | |
| const input = (name) => fieldset.querySelector(`[name="${name}"]`); | |
| const updateHint = () => { | |
| const official = select.value === "openai", remote = official || select.value === "openai-compatible", changed = select.value !== config[role].provider || input("baseUrl").value.trim().replace(/\/+$/, "") !== config[role].baseUrl; | |
| input("baseUrl").readOnly = official; | |
| input("baseUrl").placeholder = "API Base URL or full audio endpoint"; | |
| input("language").placeholder = role === "speech" ? "Language: auto, en, zh (blank = auto)" : "Browser language, e.g. en-US"; | |
| input("apiKey").placeholder = changed ? official ? "OpenAI API Key" : "Key for this service (if required)" : config[role].hasKey ? "Key saved; leave blank to keep it" : official ? "OpenAI API Key" : "API Key (if required)"; | |
| hint.textContent = role === "speech" ? official ? "Uses OpenAI audio transcription. Enter your API key and choose a transcription model." : "Uses /audio/transcriptions. Enter your service URL and model ID; a key is optional for services without authentication." : remote ? "AI-generated voice. Uses /audio/speech with your model and voice ID." : "Browser speech needs no API key. Off disables spoken replies."; | |
| if (changed) hint.textContent += " Changing the service clears its previously saved key."; | |
| for (const name of ["baseUrl", "model", "voice", "apiKey"]) if (input(name)) input(name).disabled = role === "voice" && !remote; | |
| }; | |
| select.onchange = () => { | |
| const custom = select.value === "openai-compatible"; | |
| input("baseUrl").value = custom ? "" : "https://api.openai.com/v1"; | |
| input("model").value = custom ? "" : role === "speech" ? "gpt-4o-mini-transcribe" : "gpt-4o-mini-tts"; | |
| if (role === "voice") input("voice").value = custom ? "" : "alloy"; | |
| input("language").value = role === "speech" ? "auto" : "zh-CN"; | |
| input("apiKey").value = ""; | |
| updateHint(); | |
| }; | |
| input("baseUrl").addEventListener("input", updateHint); | |
| updateHint(); | |
| } | |
| } | |
| } | |
| async function saveSettings() { | |
| const update = {}; | |
| for (const fieldset of $("provider-fields").children) { | |
| const fields = {}; | |
| for (const input of fieldset.querySelectorAll("input,select")) fields[input.name] = input.type === "checkbox" ? input.checked : input.value; | |
| update[fieldset.dataset.role] = fields; | |
| } | |
| config = await api("/api/config", update); | |
| renderConfig(); | |
| $("settings-dialog").close(); | |
| notify("API settings saved locally"); | |
| } | |
| async function refreshLibrary() { | |
| library = await api("/api/library"); | |
| $("image-library").replaceChildren(); | |
| for (const item of library) { | |
| const card = document.createElement("button"); | |
| card.className = "image-card"; | |
| card.dataset.id = item.id; | |
| const image = document.createElement("img"); | |
| image.src = item.url; | |
| image.alt = uiText(item.title); | |
| const title = document.createElement("span"); | |
| title.textContent = uiText(item.title); | |
| const tag = document.createElement("small"); | |
| tag.textContent = uiText(item.kind === "preset" ? "Preset reference" : "Generated image"); | |
| card.append(image, title, tag); | |
| card.onclick = () => chooseReference(item.id); | |
| $("image-library").append(card); | |
| } | |
| onLibrary(library); | |
| return library; | |
| } | |
| function chooseReference(id) { | |
| const item = library.find((i) => i.id === id); | |
| if (!item) return; | |
| onReference(item); | |
| document.querySelectorAll(".image-card").forEach((card) => card.classList.toggle("selected", card.dataset.id === id)); | |
| $("image-preview").src = item.url; | |
| $("image-preview").hidden = false; | |
| $("image-name").textContent = uiText("Selected: " + item.title); | |
| notify("Reference selected: " + item.title); | |
| } | |
| $("settings-open").onclick = safe(async () => { | |
| config = await api("/api/config"); | |
| renderConfig(); | |
| $("settings-dialog").showModal(); | |
| }); | |
| $("settings-close").onclick = () => $("settings-dialog").close(); | |
| $("settings-save").onclick = safe(saveSettings); | |
| $("voice-input").onclick = safe(() => recordState === "recording" ? stopVoice() : startVoice()); | |
| $("ask-codex").onclick = safe(() => sendText()); | |
| $("edit-prompt").oninput = () => onReadout({ draft: $("edit-prompt").value }); | |
| if ($("voice-talk")) $("voice-talk").onclick = $("voice-input").onclick; | |
| if ($("image-voice")) $("image-voice").onclick = safe(() => recordState === "recording" ? stopVoice() : startVoice({ destination: "image" })); | |
| if ($("conversation-toggle")) $("conversation-toggle").onclick = () => { | |
| const open = !$("text-input").open; | |
| $("text-input").open = open; | |
| $("conversation-toggle").setAttribute("aria-expanded", String(open)); | |
| }; | |
| $("generate-image").onclick = safe(() => requestJob("image", $("image-generation-prompt").value, conversationId)); | |
| addEventListener("pagehide", cancelVoice); | |
| async function initialize({ loadLibrary = true } = {}) { | |
| if (scriptedMode) { | |
| if (loadLibrary) await refreshLibrary(); | |
| return; | |
| } | |
| config = await api("/api/config"); | |
| renderConfig(); | |
| if (loadLibrary) await refreshLibrary(); | |
| renderConversation(await api("/api/conversation?id=" + conversationId)); | |
| } | |
| return { initialize, conversationId, confirmedSelection, renderConversation, refreshLibrary, chooseReference, say, startVoice, stopVoice, cancelVoice, confirmVoice, pageVoice, getVoiceDraft: voiceDraft, getSpeechState: () => ({ ...speech, automatic: automatic && recordState !== "idle" }), getRecordingState: () => recordState, getDraft: () => $("edit-prompt").value, setDraft: draft, sendText }; | |
| } | |
| export { | |
| createStudio | |
| }; | |