Fix admin authorization and harden chat requests
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user