/** Composer state, send serialization, cancellation, and streaming presentation. */ import * as defaultApi from "./api.js"; import {updateReasoning, finishReasoning} from "./reasoning.js"; import {createStreamRenderer} from "./streamRenderer.js"; export function createComposerController({ state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle, setChatActivity, updateComposerPlaceholder, setNotice, createNewChat, getChatById, updateWorkspaceCopy, renderChatList, addMessage, updateChatActionState, scrollMessagesToBottom, setMessageContent, announceResponding, announceReply, announceResponseStatus, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge, api = defaultApi }) { const {msgInput, sendBtn, stopBtn, messagesDiv, modelSelect} = elements; const composerHint = document.getElementById("composerHint"); let sendController = null; function updateSendState() { const hasText = Boolean(msgInput.value.trim()); sendBtn.disabled = state.isProcessing || attachmentComposer.pending || (!hasText && !attachmentComposer.attachments.length); stopBtn.hidden = !state.isProcessing; } function resizeComposerInput() { msgInput.style.height = "auto"; msgInput.style.height = `${Math.min(Math.max(msgInput.scrollHeight, 46), 96)}px`; } function setLoadingState(loading, overlayOptions = null) { state.isProcessing = loading; webSearchButton.disabled = loading; reasoningToggle.setBusy(loading); attachmentComposer.setBusy(loading); messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach(button => { button.disabled = loading || button.dataset.retryLimitReached === "true"; }); messagesDiv.querySelectorAll('[data-message-action="retry-failed"]').forEach(button => { button.disabled = loading; }); if (loading) { msgInput.placeholder = "Draft your next message..."; composerHint.textContent = "Draft your next message · Enter for new line"; announceResponding(); if (overlayOptions !== false) setChatActivity(true, overlayOptions || undefined); } else { setChatActivity(false); updateComposerPlaceholder(); composerHint.textContent = "Enter to send · Shift+Enter for new line"; } updateSendState(); } async function stopGeneration() { if (!state.isProcessing) return; if (sendController) { sendController.abort(); setNotice("Stopping generation..."); return; } const chatId = state.regenerationChatId; if (!chatId) return; const res = await api.post(`/chats/${chatId}/stop`, {}); if (res.error) return setNotice(res.error, "error"); setNotice(res.stopped ? "Stopping generation..." : "Generation already finished."); } async function sendMessage({retryMessage = null} = {}) { if (state.isProcessing || attachmentComposer.pending) return; const attachments = [...(retryMessage?.attachments || attachmentComposer.attachments)]; const message = retryMessage?.content || msgInput.value.trim() || (attachments.length ? "Describe the attached images." : ""); if (!message) return; // Claim the send before any async work (including creating or renaming a chat). state.isProcessing = true; const model = modelSelect.value; const webSearch = webSearchToggle.enabled, reasoning = reasoningToggle.enabled; let chatId = state.activeChatId; state.sendingChatId = chatId; sendController = new AbortController(); const {signal} = sendController; const visible = () => state.activeChatId === chatId; let userCard, loadingMsg, rendering, completedReply = null; let completedThinking = "", completedSources = null; const userMessage = {role: "user", content: message, attachments, ...(retryMessage?.id ? {id: retryMessage.id, deliveryStatus: retryMessage.deliveryStatus, errorMessage: retryMessage.errorMessage} : {})}; if (!retryMessage) { msgInput.value = ""; state.chatDrafts.set(chatId ?? "new", ""); resizeComposerInput(); } const clearSentDraft = () => { attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id)); }; try { setLoadingState(true, false); if (!chatId) { const created = await createNewChat({preserveComposer: true, signal}); signal.throwIfAborted(); if (!created?.id) throw new Error(created?.error || "Unable to create chat."); chatId = created.id; state.sendingChatId = chatId; } const currentChat = getChatById(chatId); if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") { const title = message.length > 40 ? `${message.slice(0, 40)}...` : message; const renamed = await api.put(`/chats/${chatId}`, {title}, {signal}); signal.throwIfAborted(); if (renamed.error) throw new Error(renamed.error); currentChat.title = title; updateWorkspaceCopy(); renderChatList(); } signal.throwIfAborted(); if (visible()) { state.currentMessages.push(userMessage); userCard = addMessage(userMessage, true); loadingMsg = addMessage({role: "bot", content: ""}, false, true, {isNewest: true}); } updateChatActionState(); setNotice("Generating reply..."); const loadingBody = loadingMsg?.querySelector(".msg-content"); const loadingShell = loadingMsg?.querySelector(".loading-shell"); const loadingLabel = loadingMsg?.querySelector(".loading-copy strong"); if (loadingLabel) loadingLabel.textContent = "Preparing response..."; const render = operation => { if (!visible() || !loadingMsg?.isConnected) return; const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; operation(); if (follow) scrollMessagesToBottom(); }; rendering = createStreamRenderer({ onThinking: text => { completedThinking = text; render(() => { if (!reasoning) return; updateReasoning(loadingMsg, text, true); loadingShell?.classList.toggle("hidden", Boolean(text)); }); }, onReply: text => render(() => { if (text) finishReasoning(loadingMsg); loadingShell?.classList.toggle("hidden", Boolean(text)); if (loadingLabel) loadingLabel.textContent = "Generating response..."; loadingBody?.classList.add("has-content"); if (loadingBody) setMessageContent(loadingBody, text); }) }); await api.stream("/chat/stream", {message, attachments: attachments.map(item => item.id), model, chatId, ...(retryMessage?.id && ["failed", "pending"].includes(retryMessage.deliveryStatus) ? {userMessageId: retryMessage.id} : {}), webSearch, reasoning}, { ...rendering, onStatus: ({status, sourceCount, searchState}) => { if (status === "accepted" || !visible() || !loadingLabel) return; loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..." : searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..." : sourceCount === 0 ? "No web results found. Generating response..." : "Generating response..."; }, onDone: ({reply, sources}) => { completedReply = reply; completedSources = sources; } }, {signal}); rendering.flush(); rendering.dispose(); // Keep a completed answer in both state and the DOM until history replaces it. if (visible()) { const reply = {role: "bot", content: completedReply, modelName: model, ...(completedThinking ? {thinking: completedThinking} : {}), ...(completedSources ? {sources: completedSources} : {})}; state.currentMessages.push(reply); if (loadingMsg?.isConnected) loadingMsg.replaceWith(addMessage(reply, false, false, {detached: true})); else addMessage(reply); announceReply(completedReply); } else loadingMsg?.remove(); reasoningToggle.reset(); if (visible()) { const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; const previousScrollTop = messagesDiv.scrollTop; const refreshed = await setActiveChat(chatId); if (refreshed?.error) throw new Error(refreshed.error); if (visible()) { if (msgInput.value) state.chatDrafts.set(chatId, msgInput.value); else state.chatDrafts.delete(chatId); const currentAttachments = attachmentComposer.attachments; if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear(); } else clearSentDraft(); if (!followOutput && visible()) requestAnimationFrame(() => { if (visible()) messagesDiv.scrollTop = previousScrollTop; }); } else { clearSentDraft(); } await loadSummary(); markOnboardingCompleted(); maybeShowPersonaNudge(); setNotice("Reply received.", "success"); } catch (error) { loadingMsg?.remove(); const messageText = signal.aborted ? "Generation stopped." : error?.message || "Unable to send message."; if (visible() && completedReply === null) announceResponseStatus(messageText); if (completedReply !== null) { setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error"); } else { if (visible()) { if (!msgInput.value.trim()) { msgInput.value = message; state.chatDrafts.set(state.activeChatId ?? "new", message); resizeComposerInput(); } userMessage.deliveryStatus = "failed"; userMessage.errorMessage = messageText; if (userCard?.isConnected) userCard.replaceWith(addMessage(userMessage, true, false, {detached: true})); } setNotice(messageText, "error"); } } finally { rendering?.dispose(); sendController = null; state.sendingChatId = null; setLoadingState(false); updateChatActionState(); } } return { updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage }; }