Spaces:
Runtime error
Runtime error
| import { registerServiceWorker } from "/sw-register.js"; | |
| import { buildAttachmentParts, createAttachments, createLinkAttachment, releaseAttachment } from "/chatclient/media.js"; | |
| import { createAttachmentPreviewController } from "/chatclient/preview.js"; | |
| import { loadDraft, saveDraft } from "/chatclient/draft.js"; | |
| import { renderAttachments, renderResponse, setStatus, showError } from "/chatclient/render.js"; | |
| import { loadSettings, saveSettings } from "/chatclient/settings.js"; | |
| const state = { attachments: [] }; | |
| const editor = document.querySelector("#editor"); | |
| const endpointInput = document.querySelector("#endpoint"); | |
| const modelInput = document.querySelector("#model"); | |
| const systemPromptInput = document.querySelector("#system-prompt"); | |
| const audioOutputInput = document.querySelector("#audio-output"); | |
| const voiceInput = document.querySelector("#voice"); | |
| const attachmentInput = document.querySelector("#attachment-input"); | |
| const attachmentPicker = document.querySelector("#attachment-picker"); | |
| const attachmentLinkType = document.querySelector("#attachment-link-type"); | |
| const attachmentLinkUrl = document.querySelector("#attachment-link-url"); | |
| const attachmentsCard = document.querySelector(".attachments-card"); | |
| const attachmentList = document.querySelector("#attachment-list"); | |
| const attachmentSummary = document.querySelector("#attachment-summary"); | |
| const settingsPanel = document.querySelector("#settings-panel"); | |
| const settingsToggle = document.querySelector("#settings-toggle"); | |
| const statusLine = document.querySelector("#status-line"); | |
| const sendButton = document.querySelector("#send-button"); | |
| const responseOutput = document.querySelector("#response-output"); | |
| const rawCopyButton = document.querySelector("#raw-copy-button"); | |
| const rawJson = document.querySelector("#raw-json"); | |
| const errorToast = document.querySelector("#error-toast"); | |
| const previewController = createAttachmentPreviewController(); | |
| registerServiceWorker(); | |
| loadSettings({ endpointInput, modelInput, systemPromptInput, audioOutputInput, voiceInput }); | |
| previewController.close(); | |
| restoreDraft(); | |
| renderAttachments(attachmentsCard, attachmentList, attachmentSummary, state.attachments, removeAttachment, previewController.open); | |
| bindEvents(); | |
| function bindEvents() { | |
| settingsToggle.addEventListener("click", () => toggleSettings()); | |
| audioOutputInput.addEventListener("change", syncAudioFields); | |
| attachmentInput.addEventListener("change", handleAttachmentSelect); | |
| document.querySelector("#attachment-trigger").addEventListener("click", () => toggleAttachmentPicker()); | |
| document.querySelector("#attachment-link-add").addEventListener("click", handleLinkAdd); | |
| attachmentLinkUrl.addEventListener("keydown", handleLinkKeyDown); | |
| document.querySelector("#send-button").addEventListener("click", handleSend); | |
| rawCopyButton.addEventListener("click", handleRawCopy); | |
| document.querySelector(".editor-toolbar").addEventListener("click", handleFormatClick); | |
| editor.addEventListener("keydown", handleEditorKeyDown); | |
| editor.addEventListener("input", persistDraft); | |
| for (const button of document.querySelectorAll(".tab-button")) { | |
| button.addEventListener("click", () => setActiveTab(button.dataset.tab)); | |
| } | |
| for (const button of document.querySelectorAll(".picker-mode-button")) { | |
| button.addEventListener("click", () => handleAttachmentModeSelect(button.dataset.mode)); | |
| } | |
| for (const element of [endpointInput, modelInput, systemPromptInput, attachmentLinkType]) { | |
| element.addEventListener("input", handleInputPersistence); | |
| } | |
| for (const element of [audioOutputInput, voiceInput]) { | |
| element.addEventListener("change", handleInputPersistence); | |
| } | |
| attachmentLinkUrl.addEventListener("input", persistDraft); | |
| syncAudioFields(); | |
| } | |
| function restoreDraft() { | |
| const draft = loadDraft(); | |
| editor.innerHTML = draft.editorHtml; | |
| attachmentLinkType.value = draft.attachmentLinkType; | |
| attachmentLinkUrl.value = draft.attachmentLinkUrl; | |
| state.attachments = draft.attachments; | |
| setAttachmentMode(draft.attachmentMode); | |
| } | |
| function syncAudioFields() { | |
| voiceInput.disabled = !audioOutputInput.checked; | |
| } | |
| function toggleSettings(forceOpen) { | |
| const shouldShow = typeof forceOpen === "boolean" ? forceOpen : settingsPanel.hidden; | |
| settingsPanel.hidden = !shouldShow; | |
| settingsToggle.classList.toggle("is-active", shouldShow); | |
| } | |
| function toggleAttachmentPicker(forceOpen) { | |
| const shouldShow = typeof forceOpen === "boolean" ? forceOpen : attachmentPicker.hidden; | |
| attachmentPicker.hidden = !shouldShow; | |
| document.querySelector("#attachment-trigger").classList.toggle("is-active", shouldShow); | |
| } | |
| function setAttachmentMode(mode) { | |
| for (const button of document.querySelectorAll(".picker-mode-button")) { | |
| button.classList.toggle("is-active", button.dataset.mode === mode); | |
| } | |
| for (const panel of document.querySelectorAll(".picker-panel")) { | |
| const isActive = panel.id === `attachment-picker-${mode}`; | |
| panel.hidden = !isActive; | |
| panel.classList.toggle("is-active", isActive); | |
| } | |
| persistDraft(); | |
| } | |
| function handleAttachmentModeSelect(mode) { | |
| setAttachmentMode(mode); | |
| if (mode === "upload") { | |
| attachmentInput.click(); | |
| } | |
| } | |
| function setActiveTab(tabName) { | |
| for (const button of document.querySelectorAll(".tab-button")) { | |
| const isActive = button.dataset.tab === tabName; | |
| button.classList.toggle("is-active", isActive); | |
| button.setAttribute("aria-selected", String(isActive)); | |
| } | |
| for (const panel of document.querySelectorAll(".tab-panel")) { | |
| const isActive = panel.dataset.panel === tabName; | |
| panel.hidden = !isActive; | |
| panel.classList.toggle("is-active", isActive); | |
| } | |
| } | |
| function handleFormatClick(event) { | |
| const button = event.target.closest(".format-button"); | |
| if (!button) { | |
| return; | |
| } | |
| editor.focus(); | |
| document.execCommand(button.dataset.command); | |
| persistDraft(); | |
| } | |
| function handleEditorKeyDown(event) { | |
| if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { | |
| event.preventDefault(); | |
| handleSend(); | |
| } | |
| } | |
| async function handleAttachmentSelect(event) { | |
| try { | |
| const nextItems = await createAttachments(event.target.files); | |
| if (nextItems.length === 0) { | |
| return; | |
| } | |
| addAttachments(nextItems); | |
| toggleAttachmentPicker(false); | |
| } catch (error) { | |
| showError(errorToast, error.message); | |
| } finally { | |
| attachmentInput.value = ""; | |
| } | |
| } | |
| function handleLinkAdd() { | |
| try { | |
| addAttachments([createLinkAttachment(attachmentLinkType.value, attachmentLinkUrl.value)]); | |
| attachmentLinkUrl.value = ""; | |
| toggleAttachmentPicker(false); | |
| persistDraft(); | |
| } catch (error) { | |
| showError(errorToast, error.message); | |
| } | |
| } | |
| function handleLinkKeyDown(event) { | |
| if (event.key === "Enter") { | |
| event.preventDefault(); | |
| handleLinkAdd(); | |
| } | |
| } | |
| function removeAttachment(id) { | |
| const index = state.attachments.findIndex((attachment) => attachment.id === id); | |
| if (index === -1) { | |
| return; | |
| } | |
| releaseAttachment(state.attachments[index]); | |
| state.attachments.splice(index, 1); | |
| renderAttachments(attachmentsCard, attachmentList, attachmentSummary, state.attachments, removeAttachment, previewController.open); | |
| setStatus(statusLine, state.attachments.length === 0 ? "Ready." : `${state.attachments.length} attachment(s) ready.`); | |
| persistDraft(); | |
| } | |
| function addAttachments(nextItems) { | |
| state.attachments.push(...nextItems); | |
| renderAttachments(attachmentsCard, attachmentList, attachmentSummary, state.attachments, removeAttachment, previewController.open); | |
| setStatus(statusLine, `${state.attachments.length} attachment(s) ready.`); | |
| persistDraft(); | |
| } | |
| async function handleSend() { | |
| if (sendButton.disabled) { | |
| return; | |
| } | |
| sendButton.disabled = true; | |
| setStatus(statusLine, "Sending request..."); | |
| showPendingOutput(); | |
| setActiveTab("output"); | |
| try { | |
| const payload = await buildPayload(); | |
| const response = await fetch(endpointInput.value.trim(), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify(payload) | |
| }); | |
| const data = await readResponseBody(response); | |
| rawJson.textContent = JSON.stringify(data, null, 2); | |
| if (!response.ok) { | |
| setActiveTab("raw"); | |
| throw new Error(data?.error?.message ?? `HTTP ${response.status}`); | |
| } | |
| renderResponse(responseOutput, payload, data); | |
| setActiveTab("output"); | |
| setStatus(statusLine, "Response received.", true); | |
| } catch (error) { | |
| renderRequestError(error, "request"); | |
| } finally { | |
| sendButton.disabled = false; | |
| } | |
| } | |
| async function buildPayload() { | |
| const text = readEditorText(); | |
| const content = []; | |
| const hasAudioAttachment = state.attachments.some((attachment) => attachment.kind === "audio"); | |
| if (text) { | |
| content.push({ type: "text", text }); | |
| } | |
| content.push(...await buildAttachmentParts(state.attachments)); | |
| if (content.length === 0) { | |
| throw new Error("Add text or at least one image/audio file before sending."); | |
| } | |
| const payload = { | |
| model: modelInput.value.trim(), | |
| messages: [] | |
| }; | |
| if (hasAudioAttachment) { | |
| payload.modalities = ["text", "audio"]; | |
| } | |
| if (!payload.model) { | |
| throw new Error("Enter a model name in settings."); | |
| } | |
| if (systemPromptInput.value.trim()) { | |
| payload.messages.push({ | |
| role: "system", | |
| content: systemPromptInput.value.trim() | |
| }); | |
| } | |
| payload.messages.push({ | |
| role: "user", | |
| content | |
| }); | |
| if (audioOutputInput.checked) { | |
| payload.audio = { | |
| voice: voiceInput.value, | |
| format: "mp3" | |
| }; | |
| } | |
| persistSettings(); | |
| return payload; | |
| } | |
| function readEditorText() { | |
| return editor.innerText.replaceAll("\u00A0", " ").trim(); | |
| } | |
| async function readResponseBody(response) { | |
| const text = await response.text(); | |
| if (!text) { | |
| return {}; | |
| } | |
| try { | |
| return JSON.parse(text); | |
| } catch (_error) { | |
| return { | |
| error: { | |
| message: text | |
| } | |
| }; | |
| } | |
| } | |
| function handleInputPersistence() { | |
| persistSettings(); | |
| persistDraft(); | |
| } | |
| function persistSettings() { | |
| saveSettings({ | |
| endpointInput, | |
| modelInput, | |
| systemPromptInput, | |
| audioOutputInput, | |
| voiceInput | |
| }); | |
| } | |
| function persistDraft() { | |
| saveDraft({ | |
| editor, | |
| attachmentMode: document.querySelector(".picker-mode-button.is-active")?.dataset.mode || "upload", | |
| attachmentLinkType: attachmentLinkType.value, | |
| attachmentLinkUrl: attachmentLinkUrl.value, | |
| attachments: state.attachments | |
| }); | |
| } | |
| function renderRequestError(error, stage) { | |
| rawJson.textContent = JSON.stringify({ | |
| error: { | |
| stage, | |
| name: error.name, | |
| message: error.message | |
| } | |
| }, null, 2); | |
| setActiveTab("raw"); | |
| setStatus(statusLine, "Request failed."); | |
| showError(errorToast, error.message); | |
| } | |
| function showPendingOutput() { | |
| responseOutput.innerHTML = '<p class="empty-state">Sending request...</p>'; | |
| } | |
| async function handleRawCopy() { | |
| try { | |
| await navigator.clipboard.writeText(rawJson.textContent); | |
| setStatus(statusLine, "Raw response copied.", true); | |
| rawCopyButton.textContent = "Copied"; | |
| } catch (_error) { | |
| showError(errorToast, "Failed to copy raw response."); | |
| rawCopyButton.textContent = "Copy Failed"; | |
| } | |
| window.clearTimeout(handleRawCopy.timer); | |
| handleRawCopy.timer = window.setTimeout(() => { | |
| rawCopyButton.textContent = "Copy Raw"; | |
| }, 1600); | |
| } | |
| handleRawCopy.timer = 0; | |