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 = '
Sending request...
'; } 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;