oapix / public /chatclient /app.js
woiceatus's picture
make send wav audio file works now
740e55f
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;