/** 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, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge, api = defaultApi }) { const {msgInput, sendBtn, messagesDiv, modelSelect} = elements; let sendController = null; function updateSendState() { const hasText = Boolean(msgInput.value.trim()); sendBtn.disabled = state.isProcessing ? false : attachmentComposer.pending || (!hasText && !attachmentComposer.attachments.length); sendBtn.setAttribute("aria-label", state.isProcessing ? "Stop generation" : "Send message"); } 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; msgInput.disabled = loading; webSearchButton.disabled = loading; reasoningToggle.setBusy(loading); attachmentComposer.setBusy(loading); sendBtn.classList.toggle("loading", 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 = "Processing response..."; if (overlayOptions !== false) setChatActivity(true, overlayOptions || undefined); } else { setChatActivity(false); updateComposerPlaceholder(); } 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; const userMessage = {role: "user", content: message, attachments}; try { setLoadingState(true, false); if (retryMessage && !msgInput.value.trim()) { msgInput.value = message; resizeComposerInput(); } 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 => 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, 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}) => { completedReply = reply; } }, {signal}); rendering.dispose(); loadingMsg?.remove(); webSearchToggle.reset(); reasoningToggle.reset(); if (visible()) { const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; const previousScrollTop = messagesDiv.scrollTop; if (msgInput.value.trim() === message) { msgInput.value = ""; resizeComposerInput(); } const currentAttachments = attachmentComposer.attachments; if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear(); await setActiveChat(chatId); if (!followOutput && visible()) requestAnimationFrame(() => { if (visible()) messagesDiv.scrollTop = previousScrollTop; }); } else { if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId); attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id)); } 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 (completedReply !== null) { if (visible() && userCard?.isConnected) { state.currentMessages.push({role: "bot", content: completedReply}); addMessage(completedReply); } setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error"); } else { if (visible()) { if (!msgInput.value.trim()) { msgInput.value = 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); msgInput.focus(); updateChatActionState(); } } return { updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage }; }