208 lines
11 KiB
JavaScript
208 lines
11 KiB
JavaScript
/** 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;
|
|
let completedThinking = "", completedSources = null;
|
|
const userMessage = {role: "user", content: message, attachments};
|
|
const clearSentDraft = () => {
|
|
if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId);
|
|
attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id));
|
|
};
|
|
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 => { 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, 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);
|
|
} 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.trim() === message) { msgInput.value = ""; resizeComposerInput(); }
|
|
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 (completedReply !== null) {
|
|
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
|
|
};
|
|
}
|