| 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; |
|
|