Fix admin authorization and harden chat requests
CI / check (22) (push) Successful in 10m3s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-05 22:49:29 +02:00
parent e8cd13a0af
commit c48e1b17c4
38 changed files with 4897 additions and 1257 deletions
+194
View File
@@ -0,0 +1,194 @@
/** 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
};
}