Files
KrisHD_AI/public/js/composerController.js
T
Fretux b8b06a8a33
CI / check (22) (push) Failing after 4m50s
CI / check (24) (push) Failing after 4m51s
Improve chat title generation or smth
2026-10-13 23:40:29 +02:00

249 lines
13 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, announceResponding = () => {}, announceReply = () => {}, announceResponseStatus = () => {}, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge,
api = defaultApi
}) {
const {msgInput, sendBtn, messagesDiv, modelSelect} = elements;
const stopBtn = elements.stopBtn || {hidden: true};
const composerHint = elements.composerHint || globalThis.document?.getElementById?.("composerHint") || {textContent: ""};
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;
msgInput.disabled = 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 renamed = await api.put(`/chats/${chatId}`, {title: currentChat.title}, {signal});
signal.throwIfAborted();
if (renamed.error) throw new Error(renamed.error);
}
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, chatTitle}) => {
completedReply = reply; completedSources = sources;
if (chatTitle && currentChat) {
currentChat.title = chatTitle;
updateWorkspaceCopy(); renderChatList();
}
}
}, {signal});
rendering.flush();
rendering.dispose();
const updatedChat = getChatById(chatId);
if (api === defaultApi && currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") {
const titled = await api.post(`/chats/${chatId}/title`, {message, model}, {signal});
if (!signal.aborted && titled.title) {
currentChat.title = titled.title;
updateWorkspaceCopy(); renderChatList();
}
}
if (updatedChat && completedReply !== null) {
updatedChat.last_message_preview = completedReply.slice(0, 180);
updatedChat.updated_at = new Date().toISOString();
renderChatList();
}
// 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 {
state.chatDrafts.delete(chatId);
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) {
if (visible() && !msgInput.value.trim()) {
msgInput.value = message;
state.chatDrafts.set(chatId ?? "new", message);
resizeComposerInput();
}
setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error");
} else {
if (signal.aborted && chatId == null && !msgInput.value.trim()) {
msgInput.value = message;
state.chatDrafts.set("new", message);
resizeComposerInput();
}
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
};
}