Fix admin authorization and harden chat requests
This commit is contained in:
+149
-75
@@ -1,94 +1,168 @@
|
||||
/**
|
||||
* @file Bündelt JSON-Anfragen und verarbeitet Ereignisse gestreamter Chat-Antworten im Browser.
|
||||
*/
|
||||
export async function request(url, data, method = "POST") {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {error: "Network error - please try again."};
|
||||
/** JSON requests return error records; streaming requests reject unless onError handles them. */
|
||||
export class ApiError extends Error {
|
||||
constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.requestId = requestId;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId};
|
||||
}
|
||||
}
|
||||
|
||||
export const get = (url) => request(url, null, "GET");
|
||||
export const post = (url, data) => request(url, data, "POST");
|
||||
export const put = (url, data) => request(url, data, "PUT");
|
||||
export const del = (url) => request(url, null, "DELETE");
|
||||
function requestScope({signal, timeoutMs = 30000} = {}) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"}));
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, {once: true});
|
||||
const timer = timeoutMs > 0 ? setTimeout(() => {
|
||||
controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"}));
|
||||
}, timeoutMs) : null;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); }
|
||||
};
|
||||
}
|
||||
|
||||
// Also bound body reads, even after fetch has resolved its response headers.
|
||||
function abortable(operation, signal) {
|
||||
if (signal.aborted) return Promise.reject(signal.reason);
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
|
||||
signal.addEventListener("abort", abort, {once: true});
|
||||
try {
|
||||
Promise.resolve(operation()).then(resolve, reject)
|
||||
.finally(() => signal.removeEventListener("abort", abort));
|
||||
} catch (error) {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function responseError(response, payload = {}, fallback = "", code = "") {
|
||||
const details = payload && typeof payload === "object" ? payload : {};
|
||||
const error = details.error;
|
||||
return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), {
|
||||
...details,
|
||||
status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status,
|
||||
code: details.code || error?.code || code || `HTTP_${response.status}`,
|
||||
requestId: details.requestId || response.headers.get("X-Request-ID") || null
|
||||
});
|
||||
}
|
||||
|
||||
async function readJson(response, signal) {
|
||||
const text = await abortable(() => response.text(), signal);
|
||||
let payload;
|
||||
if (text.trim()) {
|
||||
try { payload = JSON.parse(text); }
|
||||
catch {
|
||||
if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE");
|
||||
}
|
||||
}
|
||||
if (!response.ok) throw responseError(response, payload);
|
||||
return payload ?? {};
|
||||
}
|
||||
|
||||
function normalizeError(error, scope, response) {
|
||||
const failure = scope.signal.aborted ? scope.signal.reason : error;
|
||||
if (failure instanceof ApiError) {
|
||||
if (!failure.status && response) failure.status = response.status;
|
||||
failure.requestId ||= response?.headers.get("X-Request-ID") || null;
|
||||
return failure;
|
||||
}
|
||||
return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", {
|
||||
status: response?.status || 0,
|
||||
code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR",
|
||||
requestId: response?.headers.get("X-Request-ID") || null
|
||||
});
|
||||
}
|
||||
|
||||
export async function request(url, data, method = "POST", options = {}) {
|
||||
const scope = requestScope(options);
|
||||
let response;
|
||||
try {
|
||||
response = await abortable(() => fetch(url, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {"Accept": "application/json", "Content-Type": "application/json"},
|
||||
body: data == null ? undefined : JSON.stringify(data),
|
||||
signal: scope.signal
|
||||
}), scope.signal);
|
||||
return await readJson(response, scope.signal);
|
||||
} catch (error) {
|
||||
return normalizeError(error, scope, response).toJSON();
|
||||
} finally {
|
||||
scope.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const get = (url, options) => request(url, null, "GET", options);
|
||||
export const post = (url, data, options) => request(url, data, "POST", options);
|
||||
export const put = (url, data, options) => request(url, data, "PUT", options);
|
||||
export const del = (url, options) => request(url, null, "DELETE", options);
|
||||
|
||||
/**
|
||||
* @typedef {"skipped"|"failed"|"no_results"|"no_relevant_results"|"succeeded"} SearchState
|
||||
* @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number, searchState?: SearchState}}
|
||||
* | {event: "chunk", data: {chunk: string, fullReply: string}}
|
||||
* | {event: "thinking", data: {chunk: string, fullThinking: string}}
|
||||
* | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[], searchState?: SearchState}}
|
||||
* | {event: "error", data: {error: string, code?: string, searchState?: SearchState}}} ChatStreamEvent
|
||||
* SSE text events carry {chunk}; corrections carry {target: "reply"|"thinking", text}.
|
||||
* A legacy AbortSignal is also accepted as the fourth argument. Set timeoutMs: 0 to disable the deadline.
|
||||
*/
|
||||
export async function stream(url, data, handlers = {}, signal = undefined) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(data || {}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
let error = "Streaming request failed";
|
||||
try {
|
||||
const payload = await response.json();
|
||||
error = payload.error || error;
|
||||
} catch {
|
||||
// Keep the generic error if the server did not send JSON.
|
||||
}
|
||||
if (handlers.onError) {
|
||||
handlers.onError({error});
|
||||
return;
|
||||
}
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let terminal = false;
|
||||
|
||||
export async function stream(url, data, handlers = {}, options = {}) {
|
||||
if (options && typeof options.addEventListener === "function") options = {signal: options};
|
||||
const scope = requestScope({timeoutMs: 660000, ...options});
|
||||
let response, reader;
|
||||
try {
|
||||
while (true) {
|
||||
const {value, done} = await reader.read();
|
||||
response = await abortable(() => fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"},
|
||||
body: JSON.stringify(data || {}),
|
||||
signal: scope.signal
|
||||
}), scope.signal);
|
||||
if (!response.ok) await readJson(response, scope.signal);
|
||||
if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE");
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let terminal = false;
|
||||
while (!terminal) {
|
||||
const {value, done} = await abortable(() => reader.read(), scope.signal);
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const parts = buffer.split(/\r?\n\r?\n/);
|
||||
// Das letzte SSE-Ereignis kann unvollständig sein und wird mit dem nächsten Netzwerkblock ergänzt.
|
||||
buffer = parts.pop() || "";
|
||||
for (const part of parts) {
|
||||
const lines = part.split("\n");
|
||||
const eventLine = lines.find((line) => line.startsWith("event:"));
|
||||
const dataLine = lines.find((line) => line.startsWith("data:"));
|
||||
if (!eventLine || !dataLine) continue;
|
||||
const event = eventLine.slice(6).trim();
|
||||
const payload = JSON.parse(dataLine.slice(5).trim());
|
||||
const lines = part.split(/\r?\n/);
|
||||
const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();
|
||||
const dataLines = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart());
|
||||
if (!event || !dataLines.length) continue;
|
||||
const payload = JSON.parse(dataLines.join("\n"));
|
||||
if (!payload || typeof payload !== "object") throw new Error("Invalid response stream.");
|
||||
const textFields = {chunk: "fullReply", thinking: "fullThinking", status: "status", done: "reply", error: "error"};
|
||||
const textFields = {status: "status", done: "reply", error: "error", replace: "text"};
|
||||
if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream.");
|
||||
if (terminal) continue;
|
||||
if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload);
|
||||
if (event === "thinking" && handlers.onThinking) handlers.onThinking(payload);
|
||||
if (event === "status" && handlers.onStatus) handlers.onStatus(payload);
|
||||
if (event === "done" && handlers.onDone) handlers.onDone(payload);
|
||||
if (event === "error" && handlers.onError) handlers.onError(payload);
|
||||
if (event === "done" || event === "error") terminal = true;
|
||||
if (event === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new Error("Invalid response stream.");
|
||||
if (event === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new Error("Invalid response stream.");
|
||||
if (event === "replace" && !["reply", "thinking"].includes(payload.target)) throw new Error("Invalid replacement stream.");
|
||||
if (event === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR");
|
||||
const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[event];
|
||||
if (handler) handlers[handler]?.(payload);
|
||||
if (event === "done") { terminal = true; break; }
|
||||
}
|
||||
if (terminal) break;
|
||||
}
|
||||
// Ohne explizites Abschluss- oder Fehlerereignis gilt die Verbindung als vorzeitig abgebrochen.
|
||||
if (!terminal) throw new Error("Response was interrupted. Please try again.");
|
||||
if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED");
|
||||
} catch (error) {
|
||||
const failure = normalizeError(error, scope, response);
|
||||
if (handlers.onError) handlers.onError(failure.toJSON());
|
||||
else throw failure;
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
scope.close();
|
||||
if (reader) {
|
||||
// Cancelling a custom or stalled source must not prevent the request from settling.
|
||||
void reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,15 @@ export function createAttachmentComposer({onChange, onError}) {
|
||||
drafts.delete(chatId);
|
||||
render();
|
||||
},
|
||||
clearSentDraft(chatId, ids) {
|
||||
const saved = drafts.get(chatId) || [];
|
||||
const sent = new Set(ids);
|
||||
drafts.set(chatId, saved.filter(item => {
|
||||
if (!sent.has(item.metadata?.id)) return true;
|
||||
URL.revokeObjectURL(item.preview);
|
||||
return false;
|
||||
}));
|
||||
},
|
||||
discardDraft(chatId) {
|
||||
const saved = drafts.get(chatId) || [];
|
||||
drafts.delete(chatId);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import {chatSearchInput, msgInput, messagesDiv} from "./dom.js?v=20260909-account-controls";
|
||||
import {get, post} from "./api.js?v=20260909-account-controls";
|
||||
import {requestedChatId} from "./constants.js?v=20260909-account-controls";
|
||||
|
||||
export function createConversationController({
|
||||
state,
|
||||
renderChatList,
|
||||
attachmentComposer,
|
||||
resizeComposerInput,
|
||||
isMobileLayout,
|
||||
closeMobileSidebar,
|
||||
setChatLoading,
|
||||
setChatActivity,
|
||||
updateWorkspaceCopy,
|
||||
updateChatActionState,
|
||||
setNotice,
|
||||
populateGenerationSettings,
|
||||
renderMessages,
|
||||
updateJumpToLatest,
|
||||
updateSendState,
|
||||
loadSummary
|
||||
}) {
|
||||
function getChatById(id) { return state.chatSessions.find((chat) => chat.id === id); }
|
||||
|
||||
async function openCreatedChat(chat) {
|
||||
if (!chat?.id) throw new Error("The server did not return the newly created chat");
|
||||
if (chatSearchInput) {
|
||||
chatSearchInput.value = "";
|
||||
scheduleChatSearch();
|
||||
}
|
||||
const chatsResponse = await get("/chats");
|
||||
if (chatsResponse.error) throw new Error(chatsResponse.error);
|
||||
state.chatSessions = chatsResponse.chats || [];
|
||||
if (!state.chatSessions.some((item) => item.id === chat.id)) {
|
||||
throw new Error("The newly created chat was not persisted");
|
||||
}
|
||||
renderChatList();
|
||||
await setActiveChat(chat.id);
|
||||
}
|
||||
|
||||
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
|
||||
const openRequest = ++state.chatOpenRequest;
|
||||
if (!preserveComposer && id !== state.activeChatId) {
|
||||
if (state.activeChatId != null) {
|
||||
state.chatDrafts.set(state.activeChatId, msgInput.value);
|
||||
attachmentComposer.saveDraft(state.activeChatId);
|
||||
msgInput.value = state.chatDrafts.get(id) || "";
|
||||
attachmentComposer.restoreDraft(id);
|
||||
resizeComposerInput();
|
||||
} else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) {
|
||||
msgInput.value = state.chatDrafts.get(id) || "";
|
||||
attachmentComposer.restoreDraft(id);
|
||||
resizeComposerInput();
|
||||
}
|
||||
}
|
||||
if (isMobileLayout()) closeMobileSidebar();
|
||||
setChatLoading(true, "Opening chat...");
|
||||
setChatActivity(true, {
|
||||
eyebrow: "Opening chat",
|
||||
title: "Loading conversation",
|
||||
detail: "Fetching messages and restoring the current scene."
|
||||
});
|
||||
state.activeChatId = id;
|
||||
history.replaceState(null, "", `/app/chats/${id}`);
|
||||
state.messageRetryState = new Map();
|
||||
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
|
||||
let res = await get(`/chats/${id}/messages`);
|
||||
if (openRequest !== state.chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
|
||||
if (res.error) {
|
||||
setChatLoading(false);
|
||||
setChatActivity(false);
|
||||
return setNotice(res.error, "error");
|
||||
}
|
||||
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
|
||||
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
|
||||
if (!activated.error) res = await get(`/chats/${id}/messages`);
|
||||
if (openRequest !== state.chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
|
||||
}
|
||||
const existing = getChatById(id);
|
||||
if (res.chat && existing) Object.assign(existing, res.chat);
|
||||
populateGenerationSettings(res.chat || existing);
|
||||
state.currentMessages = res.messages || []; renderMessages();
|
||||
if (matchMessageId) requestAnimationFrame(() => {
|
||||
const target = [...messagesDiv.querySelectorAll("[data-message-id]")]
|
||||
.find(node => Number(node.dataset.messageId) === Number(matchMessageId));
|
||||
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
|
||||
});
|
||||
updateSendState();
|
||||
setChatLoading(false);
|
||||
setChatActivity(false);
|
||||
}
|
||||
|
||||
async function loadChatSessions() {
|
||||
setChatLoading(true, "Loading chats...");
|
||||
const res = await get("/chats");
|
||||
if (res.error) {
|
||||
setChatLoading(false);
|
||||
return setNotice(res.error, "error");
|
||||
}
|
||||
state.chatSessions = res.chats || []; renderChatList();
|
||||
if (state.activeChatId && getChatById(state.activeChatId)) {
|
||||
updateWorkspaceCopy();
|
||||
setChatLoading(false);
|
||||
return;
|
||||
}
|
||||
if (location.pathname.endsWith("/new") && !state.activeChatId) { state.currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; }
|
||||
if (requestedChatId && !getChatById(requestedChatId) && !state.activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); }
|
||||
if (state.chatSessions.length) {
|
||||
const preferredChatId = state.activeChatId && getChatById(state.activeChatId) ? state.activeChatId : (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : state.chatSessions[0].id;
|
||||
setChatLoading(false);
|
||||
return setActiveChat(preferredChatId);
|
||||
}
|
||||
state.activeChatId = null;
|
||||
state.currentMessages = [];
|
||||
renderMessages();
|
||||
updateWorkspaceCopy();
|
||||
updateSendState();
|
||||
setChatLoading(false);
|
||||
return setNotice("Pick a starting action to begin.", "success");
|
||||
}
|
||||
|
||||
async function searchChatSessions(query, requestId) {
|
||||
const res = await get(`/chats/search?q=${encodeURIComponent(query)}`);
|
||||
if (requestId !== state.chatSearchRequest || chatSearchInput.value.trim() !== query) return;
|
||||
if (res.error) {
|
||||
state.chatSearchError = "Unable to search chats.";
|
||||
renderChatList();
|
||||
setNotice(res.error, "error");
|
||||
return;
|
||||
}
|
||||
state.chatSearchResults = res.chats || [];
|
||||
renderChatList();
|
||||
}
|
||||
|
||||
function scheduleChatSearch() {
|
||||
clearTimeout(state.chatSearchTimer);
|
||||
const requestId = ++state.chatSearchRequest;
|
||||
const query = chatSearchInput.value.trim();
|
||||
state.chatSearchResults = null;
|
||||
state.chatSearchError = "";
|
||||
if (!query) {
|
||||
renderChatList();
|
||||
return;
|
||||
}
|
||||
renderChatList();
|
||||
state.chatSearchTimer = setTimeout(() => { void searchChatSessions(query, requestId); }, 300);
|
||||
}
|
||||
|
||||
async function createNewChat({preserveComposer = false, signal} = {}) {
|
||||
const previousChatId = state.activeChatId;
|
||||
setChatLoading(true, "Creating chat...");
|
||||
setChatActivity(true, {
|
||||
eyebrow: "Creating chat", title: "Opening a new conversation",
|
||||
detail: "Setting up a fresh chat so you can start sending messages."
|
||||
});
|
||||
try {
|
||||
const res = await post("/chats", {title: "New chat"}, {signal});
|
||||
if (res.error || !res.chat) {
|
||||
const error = res.error || "Unable to create chat.";
|
||||
setNotice(error, "error");
|
||||
return {error};
|
||||
}
|
||||
state.chatSessions.unshift(res.chat);
|
||||
await loadSummary(); renderChatList();
|
||||
if (!signal?.aborted && state.activeChatId === previousChatId) {
|
||||
await setActiveChat(res.chat.id, {preserveComposer});
|
||||
msgInput.focus();
|
||||
}
|
||||
setNotice("New chat created.", "success");
|
||||
return res.chat;
|
||||
} finally {
|
||||
setChatLoading(false); setChatActivity(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getChatById,
|
||||
openCreatedChat,
|
||||
setActiveChat,
|
||||
loadChatSessions,
|
||||
scheduleChatSearch,
|
||||
createNewChat
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Shared conversation data; controllers receive an instance rather than owning globals. */
|
||||
export function createConversationState() {
|
||||
return {
|
||||
isProcessing: false,
|
||||
activeChatId: null,
|
||||
editingPersonaId: null,
|
||||
editingPersonaType: "assistant",
|
||||
currentUsername: "",
|
||||
regenerationChatId: null,
|
||||
chatSessions: [],
|
||||
currentMessages: [],
|
||||
assistantPersonas: [],
|
||||
userPersonas: [],
|
||||
activeUserPersonaId: null,
|
||||
currentSummary: null,
|
||||
publishedPersonaIds: new Set(),
|
||||
roleplayPresetCharacterId: null,
|
||||
roleplayStarterStep: 1,
|
||||
messageRetryState: new Map(),
|
||||
chatSearchResults: null,
|
||||
chatSearchError: "",
|
||||
chatSearchTimer: null,
|
||||
chatSearchRequest: 0,
|
||||
chatOpenRequest: 0,
|
||||
chatDrafts: new Map(),
|
||||
sendingChatId: null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import {messagesDiv, msgInput} from "./dom.js?v=20260909-account-controls";
|
||||
import {appendAttachments} from "./attachments.js?v=20260930-chat-usability";
|
||||
import {updateReasoning} from "./reasoning.js?v=20260921-reasoning-capability";
|
||||
import {appendSources} from "./webSearch.js";
|
||||
|
||||
export function createMessageRenderer({
|
||||
getChatById,
|
||||
state,
|
||||
setNotice,
|
||||
sendMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
branchFromMessage,
|
||||
retryLatestAssistantMessage,
|
||||
switchResponseVersion,
|
||||
switchRetryVariant,
|
||||
renderDrawerSummary,
|
||||
updateChatActionState,
|
||||
updateComposerPlaceholder,
|
||||
createNewChat,
|
||||
openRoleplayStarter,
|
||||
openPersonaForm
|
||||
}) {
|
||||
const jumpToLatest = document.getElementById("jumpToLatest");
|
||||
|
||||
let scrollFrame = null;
|
||||
|
||||
let renderedChatId = null;
|
||||
|
||||
let renderedMessages = new Map();
|
||||
|
||||
function setMessageContent(el, content) {
|
||||
const markdownParser = window.marked;
|
||||
const purifier = window.DOMPurify;
|
||||
if (markdownParser?.parse && purifier?.sanitize) {
|
||||
el.innerHTML = purifier.sanitize(markdownParser.parse(content, { breaks: true }));
|
||||
} else {
|
||||
el.textContent = content;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollMessagesToBottom() {
|
||||
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = null;
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
updateJumpToLatest();
|
||||
});
|
||||
}
|
||||
|
||||
function updateJumpToLatest() {
|
||||
const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight;
|
||||
jumpToLatest.classList.toggle("hidden", distance < 180);
|
||||
}
|
||||
|
||||
function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) {
|
||||
const msgDiv = document.createElement("div");
|
||||
msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`;
|
||||
msgDiv.setAttribute("role", "article");
|
||||
msgDiv.tabIndex = 0;
|
||||
if (isLoading) {
|
||||
msgDiv.setAttribute("aria-label", "Assistant is responding");
|
||||
msgDiv.innerHTML = `
|
||||
<div class="msg-header">Assistant</div>
|
||||
<div class="loading-shell" aria-live="polite" aria-label="Assistant is responding">
|
||||
<div class="loading-bars">
|
||||
<span class="loading-bar"></span>
|
||||
<span class="loading-bar"></span>
|
||||
<span class="loading-bar"></span>
|
||||
<span class="loading-bar"></span>
|
||||
</div>
|
||||
<div class="loading-copy">
|
||||
<strong>Thinking</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-content streaming-content"></div>`;
|
||||
} else {
|
||||
const message = typeof contentOrMessage === "object" && contentOrMessage
|
||||
? contentOrMessage
|
||||
: { id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "") };
|
||||
const isMessageUser = message.role === "user";
|
||||
msgDiv.dataset.messageId = message.id;
|
||||
msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`;
|
||||
msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message");
|
||||
const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div");
|
||||
header.className = "msg-header"; body.className = "msg-content";
|
||||
actions.className = "msg-actions";
|
||||
actions.setAttribute("aria-label", "Message actions");
|
||||
const activeChat = getChatById(state.activeChatId);
|
||||
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
|
||||
setMessageContent(body, message.content);
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.type = "button";
|
||||
copyBtn.className = "msg-action-btn";
|
||||
copyBtn.textContent = "Copy";
|
||||
copyBtn.setAttribute("aria-label", `Copy ${isMessageUser ? "your" : "assistant"} message`);
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content || "");
|
||||
setNotice("Message copied.", "success");
|
||||
} catch {
|
||||
setNotice("Unable to copy message.", "error");
|
||||
}
|
||||
});
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
if (message.deliveryStatus === "failed" && isMessageUser) {
|
||||
const retryFailedBtn = document.createElement("button");
|
||||
retryFailedBtn.type = "button";
|
||||
retryFailedBtn.className = "msg-action-btn";
|
||||
retryFailedBtn.dataset.messageAction = "retry-failed";
|
||||
retryFailedBtn.textContent = "Retry";
|
||||
retryFailedBtn.disabled = state.isProcessing;
|
||||
retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); });
|
||||
actions.appendChild(retryFailedBtn);
|
||||
}
|
||||
|
||||
const menu = document.createElement("details");
|
||||
menu.className = "msg-action-menu";
|
||||
const menuTrigger = document.createElement("summary");
|
||||
menuTrigger.className = "msg-action-btn";
|
||||
menuTrigger.textContent = "More";
|
||||
menuTrigger.setAttribute("aria-label", "More message actions");
|
||||
const menuItems = document.createElement("div");
|
||||
menuItems.className = "msg-action-menu-items";
|
||||
menuItems.addEventListener("click", () => { menu.open = false; });
|
||||
menu.append(menuTrigger, menuItems);
|
||||
if (message.id) actions.appendChild(menu);
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.className = "msg-action-btn";
|
||||
editBtn.textContent = isMessageUser ? "Edit & resend" : "Edit";
|
||||
editBtn.setAttribute("aria-label", `Edit ${isMessageUser ? "your" : "assistant"} message`);
|
||||
editBtn.addEventListener("click", () => {
|
||||
void editChatMessage(message.id, options.messageIndex ?? -1);
|
||||
});
|
||||
menuItems.appendChild(editBtn);
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.className = "msg-action-btn";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.setAttribute("aria-label", `Delete ${isMessageUser ? "your" : "assistant"} message`);
|
||||
deleteBtn.addEventListener("click", () => {
|
||||
void deleteChatMessage(message.id, options.messageIndex ?? -1);
|
||||
});
|
||||
menuItems.appendChild(deleteBtn);
|
||||
|
||||
const branchBtn = document.createElement("button");
|
||||
branchBtn.type = "button";
|
||||
branchBtn.className = "msg-action-btn";
|
||||
branchBtn.textContent = "Branch here";
|
||||
branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); });
|
||||
menuItems.appendChild(branchBtn);
|
||||
|
||||
if (message.role === "bot") {
|
||||
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
|
||||
const retryBtn = document.createElement("button");
|
||||
retryBtn.type = "button";
|
||||
retryBtn.className = "msg-action-btn";
|
||||
retryBtn.dataset.messageAction = "regenerate";
|
||||
retryBtn.dataset.retryLimitReached = String(retriesUsed >= 5);
|
||||
retryBtn.textContent = `Regenerate (${retriesUsed}/5)`;
|
||||
retryBtn.setAttribute("aria-label", `Regenerate assistant message, ${retriesUsed} of 5 retries used`);
|
||||
retryBtn.disabled = retriesUsed >= 5 || state.isProcessing;
|
||||
retryBtn.addEventListener("click", () => {
|
||||
void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1);
|
||||
});
|
||||
actions.appendChild(retryBtn);
|
||||
}
|
||||
|
||||
for (const versioned of [true, false]) {
|
||||
if (Number((versioned ? message.versionCount : message.siblingCount) || 1) <= 1) continue;
|
||||
const navigate = versioned ? switchResponseVersion : switchRetryVariant;
|
||||
const prevBtn = document.createElement("button");
|
||||
prevBtn.type = "button";
|
||||
prevBtn.className = "msg-action-btn";
|
||||
prevBtn.textContent = "Prev";
|
||||
prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch");
|
||||
prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId;
|
||||
prevBtn.addEventListener("click", () => {
|
||||
void navigate(message.id, -1, options.messageIndex ?? -1);
|
||||
});
|
||||
|
||||
const nextBtn = document.createElement("button");
|
||||
nextBtn.type = "button";
|
||||
nextBtn.className = "msg-action-btn";
|
||||
nextBtn.textContent = "Next";
|
||||
nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch");
|
||||
nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId;
|
||||
nextBtn.addEventListener("click", () => {
|
||||
void navigate(message.id, 1, options.messageIndex ?? -1);
|
||||
});
|
||||
|
||||
const indexBadge = document.createElement("span");
|
||||
indexBadge.className = "msg-variant-index";
|
||||
indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`;
|
||||
actions.append(prevBtn, indexBadge, nextBtn);
|
||||
}
|
||||
|
||||
msgDiv.append(header);
|
||||
appendAttachments(msgDiv, message.attachments);
|
||||
msgDiv.append(body);
|
||||
if (!isMessageUser) updateReasoning(msgDiv, message.thinking);
|
||||
if (!isMessageUser) appendSources(msgDiv, message.sources);
|
||||
if (message.deliveryStatus === "failed" && message.errorMessage) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "status error";
|
||||
error.textContent = message.errorMessage;
|
||||
msgDiv.appendChild(error);
|
||||
}
|
||||
if (actions.childElementCount) {
|
||||
msgDiv.appendChild(actions);
|
||||
}
|
||||
}
|
||||
if (!options.detached) {
|
||||
messagesDiv.appendChild(msgDiv);
|
||||
scrollMessagesToBottom();
|
||||
}
|
||||
return msgDiv;
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
||||
const sameChat = renderedChatId === state.activeChatId;
|
||||
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
|
||||
const previousScrollTop = messagesDiv.scrollTop;
|
||||
const chat = getChatById(state.activeChatId);
|
||||
if (!state.currentMessages.length && (!chat || chat.assistant_persona_id)) {
|
||||
messagesDiv.replaceChildren();
|
||||
renderMessageEmptyState();
|
||||
renderedMessages = new Map();
|
||||
} else {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const nextRendered = new Map();
|
||||
state.currentMessages.forEach((msg, index) => {
|
||||
const key = msg.id ? String(msg.id) : null;
|
||||
const snapshot = JSON.stringify(msg);
|
||||
const previous = sameChat && key ? renderedMessages.get(key) : null;
|
||||
const card = previous?.snapshot === snapshot && previous.node.isConnected
|
||||
? previous.node
|
||||
: addMessage(msg, msg.role === "user", false, {
|
||||
messageIndex: index,
|
||||
isNewest: index === state.currentMessages.length - 1,
|
||||
detached: true
|
||||
});
|
||||
fragment.append(card);
|
||||
if (key) nextRendered.set(key, {snapshot, node: card});
|
||||
});
|
||||
messagesDiv.replaceChildren(fragment);
|
||||
renderedMessages = nextRendered;
|
||||
}
|
||||
renderedChatId = state.activeChatId;
|
||||
renderDrawerSummary();
|
||||
updateChatActionState();
|
||||
updateComposerPlaceholder();
|
||||
if (wasReadingEarlier) {
|
||||
messagesDiv.scrollTop = previousScrollTop;
|
||||
updateJumpToLatest();
|
||||
} else if (state.currentMessages.length) {
|
||||
scrollMessagesToBottom();
|
||||
} else {
|
||||
updateJumpToLatest();
|
||||
}
|
||||
}
|
||||
|
||||
function createStarterAction(label, className, handler) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = className;
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function createMessageStarterPrompt(label, prompt) {
|
||||
return createStarterAction(label, "secondary-action", async () => {
|
||||
if (!state.activeChatId) await createNewChat();
|
||||
msgInput.value = prompt;
|
||||
msgInput.dispatchEvent(new Event("input"));
|
||||
msgInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessageEmptyState() {
|
||||
const empty = document.createElement("section");
|
||||
const title = document.createElement("h3");
|
||||
const description = document.createElement("p");
|
||||
const actions = document.createElement("div");
|
||||
const chat = getChatById(state.activeChatId);
|
||||
|
||||
empty.className = "messages-empty";
|
||||
title.className = "messages-empty-title";
|
||||
description.className = "messages-empty-copy";
|
||||
actions.className = "messages-empty-actions";
|
||||
|
||||
if (!chat) {
|
||||
title.textContent = "Where shall we start?";
|
||||
description.textContent = "Choose a starting point.";
|
||||
actions.append(
|
||||
createStarterAction("New chat", "primary-action", () => { void createNewChat(); }),
|
||||
createStarterAction("Roleplay", "secondary-action", () => {
|
||||
if (state.assistantPersonas.length) {
|
||||
openRoleplayStarter();
|
||||
return;
|
||||
}
|
||||
openPersonaForm(null, "assistant");
|
||||
}),
|
||||
createMessageStarterPrompt("Try a prompt", "Help me plan my day in 3 steps.")
|
||||
);
|
||||
} else if (chat.assistant_persona_id) {
|
||||
title.textContent = "Roleplay is ready";
|
||||
description.textContent = "Send a line or pick a prompt.";
|
||||
actions.append(
|
||||
createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."),
|
||||
createMessageStarterPrompt("Set the scene", "Describe what your character notices first in this scene."),
|
||||
createStarterAction("Change setup", "secondary-action", () => openRoleplayStarter())
|
||||
);
|
||||
} else {
|
||||
title.textContent = "What can I help with?";
|
||||
description.textContent = "Write a message below or start with one of these prompts.";
|
||||
actions.append(
|
||||
createMessageStarterPrompt("Explain something", "Explain this topic in simple terms: "),
|
||||
createMessageStarterPrompt("Plan a task", "Help me make a practical step-by-step plan for: "),
|
||||
createMessageStarterPrompt("Brainstorm ideas", "Give me several creative ideas for: ")
|
||||
);
|
||||
}
|
||||
|
||||
empty.append(title, description, actions);
|
||||
messagesDiv.appendChild(empty);
|
||||
}
|
||||
|
||||
return {
|
||||
setMessageContent,
|
||||
scrollMessagesToBottom,
|
||||
updateJumpToLatest,
|
||||
addMessage,
|
||||
renderMessages
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
personaFormNotice,
|
||||
roleplayCharacterSelect,
|
||||
roleplayUserPersonaSelect,
|
||||
roleplayStarterSuggestions,
|
||||
roleplayScenarioInput,
|
||||
roleplayStarterNotice,
|
||||
roleplayStarterStepOne,
|
||||
roleplayStarterStepTwo,
|
||||
roleplayStarterBack,
|
||||
roleplayStarterTitle,
|
||||
roleplayStarterIntroCopy,
|
||||
roleplayStarterCancel,
|
||||
roleplayStarterConfirm,
|
||||
activeUserPersonaStatus,
|
||||
chatDrawerPersonaValue,
|
||||
headerPersonaName,
|
||||
clearUserPersonaBtn,
|
||||
personaModal,
|
||||
personaNameInput,
|
||||
personaFormTitle,
|
||||
personaTypeSelect,
|
||||
personaPronounsInput,
|
||||
personaAppearanceInput,
|
||||
personaBackgroundInput,
|
||||
personaDetailsInput,
|
||||
personaExampleDialoguesInput,
|
||||
personaExamplesField,
|
||||
roleplayStarterModal,
|
||||
userPersonaList,
|
||||
modelSelect
|
||||
} from "./dom.js?v=20260909-account-controls";
|
||||
import {closeChatDrawer} from "./chatDrawer.js?v=20260909-account-controls";
|
||||
import {openModal, closeModal} from "./modalFocus.js";
|
||||
import {get, post, put, del} from "./api.js?v=20260909-account-controls";
|
||||
|
||||
export function createPersonaController({
|
||||
state,
|
||||
setNotice,
|
||||
closePopup,
|
||||
updateWorkspaceCopy,
|
||||
loadChatSessions,
|
||||
confirmPopup,
|
||||
setChatLoading,
|
||||
setChatActivity,
|
||||
setActiveChat
|
||||
}) {
|
||||
function setPersonaFormNotice(message = "", noticeState = "") {
|
||||
if (!personaFormNotice) return;
|
||||
if (!message) {
|
||||
personaFormNotice.textContent = "";
|
||||
personaFormNotice.className = "status hidden";
|
||||
return;
|
||||
}
|
||||
personaFormNotice.textContent = message;
|
||||
personaFormNotice.className = noticeState ? `status ${noticeState}` : "status";
|
||||
}
|
||||
|
||||
function getPersonaName(id, items) {
|
||||
return items.find((persona) => persona.id === id)?.name || "";
|
||||
}
|
||||
|
||||
function getRoleplayStarterSuggestions() {
|
||||
const assistantPersonaId = Number(roleplayCharacterSelect?.value);
|
||||
const userPersonaId = roleplayUserPersonaSelect?.value ? Number(roleplayUserPersonaSelect.value) : null;
|
||||
const assistantName = getPersonaName(assistantPersonaId, state.assistantPersonas) || "the character";
|
||||
const userName = userPersonaId ? getPersonaName(userPersonaId, state.userPersonas) : "me";
|
||||
return [
|
||||
`${assistantName} unexpectedly crosses paths with ${userName} in a place that matters to them both.`,
|
||||
`${assistantName} already knows something important about ${userName} and confronts them with it.`,
|
||||
`${assistantName} and ${userName} are forced to cooperate while something goes wrong around them.`,
|
||||
`${assistantName} meets ${userName} in a quiet, intimate scene with unresolved tension.`,
|
||||
`${assistantName} draws ${userName} into a high-stakes problem that starts immediately.`
|
||||
];
|
||||
}
|
||||
|
||||
function updateRoleplaySuggestions() {
|
||||
if (!roleplayStarterSuggestions) return;
|
||||
roleplayStarterSuggestions.innerHTML = "";
|
||||
getRoleplayStarterSuggestions().forEach((suggestion) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "prompt-chip";
|
||||
button.textContent = suggestion;
|
||||
button.addEventListener("click", () => {
|
||||
if (roleplayScenarioInput) {
|
||||
roleplayScenarioInput.value = suggestion;
|
||||
roleplayScenarioInput.focus();
|
||||
}
|
||||
});
|
||||
roleplayStarterSuggestions.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function setRoleplayStarterNotice(message = "", noticeState = "") {
|
||||
if (!roleplayStarterNotice) return;
|
||||
if (!message) {
|
||||
roleplayStarterNotice.textContent = "";
|
||||
roleplayStarterNotice.className = "status hidden";
|
||||
return;
|
||||
}
|
||||
roleplayStarterNotice.textContent = message;
|
||||
roleplayStarterNotice.className = noticeState ? `status ${noticeState}` : "status";
|
||||
}
|
||||
|
||||
function renderRoleplayStarterStep() {
|
||||
const isSceneStep = state.roleplayStarterStep === 2;
|
||||
if (roleplayStarterStepOne) roleplayStarterStepOne.classList.toggle("hidden", isSceneStep);
|
||||
if (roleplayStarterStepTwo) roleplayStarterStepTwo.classList.toggle("hidden", !isSceneStep);
|
||||
if (roleplayStarterBack) roleplayStarterBack.classList.toggle("hidden", !isSceneStep);
|
||||
if (roleplayStarterTitle) {
|
||||
roleplayStarterTitle.textContent = isSceneStep ? "Shape the opening scene" : "Choose your roleplay";
|
||||
}
|
||||
if (roleplayStarterIntroCopy) {
|
||||
roleplayStarterIntroCopy.textContent = isSceneStep
|
||||
? "Add an optional custom scenario. If you leave it empty, the AI Character will invent its own opening scene."
|
||||
: "Pick a character and choose who you are before moving on to the opening scene.";
|
||||
}
|
||||
if (roleplayStarterCancel) {
|
||||
roleplayStarterCancel.textContent = isSceneStep ? "Close" : "Cancel";
|
||||
}
|
||||
if (roleplayStarterConfirm) {
|
||||
roleplayStarterConfirm.textContent = isSceneStep ? "Start roleplay" : "Continue";
|
||||
}
|
||||
}
|
||||
|
||||
function renderPersonaSection() {
|
||||
updateActivePersonaStatus(state.userPersonas, state.activeUserPersonaId, activeUserPersonaStatus, "No default user persona set.", "Default user persona");
|
||||
if (chatDrawerPersonaValue) {
|
||||
const active = state.userPersonas.find((persona) => persona.id === state.activeUserPersonaId);
|
||||
chatDrawerPersonaValue.textContent = active ? active.name : "None";
|
||||
if (headerPersonaName) headerPersonaName.textContent = active ? active.name : "None";
|
||||
}
|
||||
if (clearUserPersonaBtn) clearUserPersonaBtn.disabled = !state.activeUserPersonaId;
|
||||
}
|
||||
|
||||
function maybeShowPersonaNudge() {
|
||||
if (localStorage.getItem("krishd-persona-nudge-shown") === "true") return;
|
||||
const assistantOnlyChats = state.chatSessions.filter((chat) => !chat.assistant_persona_id).length;
|
||||
if (assistantOnlyChats < 3) return;
|
||||
localStorage.setItem("krishd-persona-nudge-shown", "true");
|
||||
setNotice("Want to personalize responses? Try personas in Tools.", "success");
|
||||
}
|
||||
|
||||
function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix) {
|
||||
if (!statusEl) return;
|
||||
const active = items.find((persona) => persona.id === activeId);
|
||||
statusEl.textContent = active ? `${prefix}: ${active.name}` : emptyText;
|
||||
}
|
||||
|
||||
function openPersonaForm(persona = null, personaType = "assistant") {
|
||||
closeChatDrawer();
|
||||
closePopup(false);
|
||||
openModal(personaModal, personaNameInput);
|
||||
if (persona) {
|
||||
state.editingPersonaId = persona.id; state.editingPersonaType = persona.persona_type || "assistant";
|
||||
personaFormTitle.textContent = `Edit ${state.editingPersonaType === "user" ? "your persona" : "AI Character"}`;
|
||||
personaTypeSelect.value = state.editingPersonaType; personaTypeSelect.disabled = true;
|
||||
personaNameInput.value = persona.name || ""; personaPronounsInput.value = persona.pronouns || "";
|
||||
personaAppearanceInput.value = persona.appearance || ""; personaBackgroundInput.value = persona.background || ""; personaDetailsInput.value = persona.details || "";
|
||||
personaExampleDialoguesInput.value = persona.example_dialogues || "";
|
||||
} else {
|
||||
state.editingPersonaId = null; state.editingPersonaType = personaType;
|
||||
personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI Character"}`;
|
||||
personaTypeSelect.value = personaType; personaTypeSelect.disabled = false;
|
||||
personaNameInput.value = ""; personaPronounsInput.value = ""; personaAppearanceInput.value = ""; personaBackgroundInput.value = ""; personaDetailsInput.value = "";
|
||||
personaExampleDialoguesInput.value = "";
|
||||
}
|
||||
personaExamplesField.classList.toggle("hidden", personaTypeSelect.value !== "assistant");
|
||||
setPersonaFormNotice("");
|
||||
personaNameInput.focus();
|
||||
}
|
||||
|
||||
function closePersonaForm() { closeModal(personaModal); state.editingPersonaId = null; state.editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
|
||||
|
||||
function populateRoleplayStarter() {
|
||||
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
|
||||
roleplayCharacterSelect.innerHTML = "";
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Choose AI Character";
|
||||
placeholder.disabled = true;
|
||||
placeholder.selected = true;
|
||||
roleplayCharacterSelect.appendChild(placeholder);
|
||||
state.assistantPersonas.forEach((persona) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(persona.id);
|
||||
option.textContent = persona.name;
|
||||
option.selected = persona.id === state.roleplayPresetCharacterId;
|
||||
roleplayCharacterSelect.appendChild(option);
|
||||
});
|
||||
if (state.roleplayPresetCharacterId) {
|
||||
roleplayCharacterSelect.value = String(state.roleplayPresetCharacterId);
|
||||
}
|
||||
roleplayUserPersonaSelect.innerHTML = "";
|
||||
const noneOption = document.createElement("option");
|
||||
noneOption.value = "";
|
||||
noneOption.textContent = "Myself (no user persona)";
|
||||
roleplayUserPersonaSelect.appendChild(noneOption);
|
||||
state.userPersonas.forEach((persona) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(persona.id);
|
||||
option.textContent = persona.name;
|
||||
option.selected = persona.id === state.activeUserPersonaId;
|
||||
roleplayUserPersonaSelect.appendChild(option);
|
||||
});
|
||||
if (state.assistantPersonas.length === 0) {
|
||||
setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error");
|
||||
roleplayStarterConfirm.disabled = true;
|
||||
} else {
|
||||
setRoleplayStarterNotice("");
|
||||
roleplayStarterConfirm.disabled = false;
|
||||
}
|
||||
updateRoleplaySuggestions();
|
||||
renderRoleplayStarterStep();
|
||||
}
|
||||
|
||||
function openRoleplayStarter(characterId = null) {
|
||||
closeChatDrawer();
|
||||
state.roleplayPresetCharacterId = characterId;
|
||||
state.roleplayStarterStep = 1;
|
||||
populateRoleplayStarter();
|
||||
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
||||
setRoleplayStarterNotice("");
|
||||
openModal(roleplayStarterModal, roleplayCharacterSelect);
|
||||
}
|
||||
|
||||
function closeRoleplayStarter() {
|
||||
if (!roleplayStarterModal) return;
|
||||
closeModal(roleplayStarterModal);
|
||||
state.roleplayPresetCharacterId = null;
|
||||
state.roleplayStarterStep = 1;
|
||||
roleplayStarterConfirm.disabled = false;
|
||||
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
||||
setRoleplayStarterNotice("");
|
||||
renderRoleplayStarterStep();
|
||||
}
|
||||
|
||||
function goToRoleplayStarterSceneStep() {
|
||||
if (!state.assistantPersonas.length) {
|
||||
return setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error");
|
||||
}
|
||||
if (!roleplayCharacterSelect.value) {
|
||||
return setRoleplayStarterNotice("Choose a character to continue.", "error");
|
||||
}
|
||||
state.roleplayStarterStep = 2;
|
||||
setRoleplayStarterNotice("");
|
||||
renderRoleplayStarterStep();
|
||||
if (roleplayScenarioInput) roleplayScenarioInput.focus();
|
||||
}
|
||||
|
||||
function renderPersonaList(items, activeId, listElement, personaType) {
|
||||
if (!listElement) {
|
||||
renderPersonaSection();
|
||||
return;
|
||||
}
|
||||
listElement.innerHTML = "";
|
||||
if (!items.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "status persona-status";
|
||||
empty.textContent = "No personas yet. Create one to get started.";
|
||||
listElement.appendChild(empty);
|
||||
renderPersonaSection();
|
||||
return;
|
||||
}
|
||||
items.forEach((persona) => {
|
||||
const item = document.createElement("div"), titleRow = document.createElement("div"), title = document.createElement("h4");
|
||||
const tag = document.createElement("span"), meta = document.createElement("p"), actions = document.createElement("div");
|
||||
item.className = `persona-item${personaType === "user" && persona.id === activeId ? " active" : ""}`; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions";
|
||||
tag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`; tag.textContent = personaType === "user" ? "You" : "AI";
|
||||
title.textContent = persona.name; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
|
||||
[[personaType === "assistant" ? "Start roleplay" : "Set default", () => equipPersona(persona.id, personaType), personaType === "user" && persona.id === activeId],
|
||||
["Edit", () => openPersonaForm(persona), false],
|
||||
[state.publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id), false],
|
||||
["Delete", () => deletePersona(persona.id), false]]
|
||||
.forEach(([label, handler, disabled]) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button"; btn.textContent = label; btn.disabled = disabled; btn.addEventListener("click", handler); actions.appendChild(btn);
|
||||
});
|
||||
if (state.publishedPersonaIds.has(persona.id)) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button"; btn.textContent = "Unpublish"; btn.addEventListener("click", () => unpublishPersona(persona.id)); actions.appendChild(btn);
|
||||
}
|
||||
titleRow.append(title, tag); item.append(titleRow, meta, actions); listElement.appendChild(item);
|
||||
});
|
||||
renderPersonaSection();
|
||||
}
|
||||
|
||||
async function loadPersonas() {
|
||||
const res = await get("/personas");
|
||||
if (res.error) return setNotice(res.error, "error");
|
||||
state.assistantPersonas = res.assistantPersonas || []; state.userPersonas = res.userPersonas || [];
|
||||
state.activeUserPersonaId = res.activeUserPersonaId || null; state.publishedPersonaIds = new Set(res.publishedPersonaIds || []);
|
||||
renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user");
|
||||
populateRoleplayStarter();
|
||||
renderPersonaSection();
|
||||
updateWorkspaceCopy();
|
||||
}
|
||||
|
||||
async function equipPersona(id, type) {
|
||||
if (type === "assistant") {
|
||||
openRoleplayStarter(id);
|
||||
return;
|
||||
}
|
||||
const res = await post(`/personas/${id}/equip-user`, {});
|
||||
if (res.error) return setNotice(res.error, "error");
|
||||
state.activeUserPersonaId = id;
|
||||
renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user");
|
||||
setNotice("User persona updated.", "success");
|
||||
}
|
||||
|
||||
async function clearPersona(type) {
|
||||
if (type !== "user") return;
|
||||
const res = await post("/personas/user/clear", {});
|
||||
if (res.error) return setNotice(res.error, "error");
|
||||
state.activeUserPersonaId = null;
|
||||
renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user");
|
||||
setNotice("Persona cleared.", "success");
|
||||
}
|
||||
|
||||
async function savePersona() {
|
||||
const payload = {
|
||||
personaType: personaTypeSelect.value,
|
||||
name: personaNameInput.value.trim(),
|
||||
pronouns: personaPronounsInput.value.trim(),
|
||||
appearance: personaAppearanceInput.value.trim(),
|
||||
background: personaBackgroundInput.value.trim(),
|
||||
details: personaDetailsInput.value.trim(),
|
||||
exampleDialogues: personaTypeSelect.value === "assistant" ? personaExampleDialoguesInput.value.trim() : ""
|
||||
};
|
||||
if (!payload.name) return setPersonaFormNotice("Persona name is required.", "error");
|
||||
const res = state.editingPersonaId ? await put(`/personas/${state.editingPersonaId}`, payload) : await post("/personas", payload);
|
||||
if (res.error) return setPersonaFormNotice(res.error, "error");
|
||||
const wasEditing = Boolean(state.editingPersonaId);
|
||||
await loadPersonas();
|
||||
if (payload.personaType === "assistant") await loadChatSessions();
|
||||
closePersonaForm(); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success");
|
||||
}
|
||||
|
||||
async function deletePersona(id) {
|
||||
const persona = state.assistantPersonas.find((item) => item.id === id) || state.userPersonas.find((item) => item.id === id);
|
||||
if (!persona) return;
|
||||
const confirmed = await confirmPopup({
|
||||
eyebrow: persona.persona_type === "user" ? "Your persona" : "AI Character",
|
||||
title: "Delete persona",
|
||||
description: `Delete "${persona.name}"? This removes it from your library and any active slot.`,
|
||||
confirmLabel: "Delete",
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) return;
|
||||
const res = await del(`/personas/${id}`);
|
||||
if (res.error) return setNotice(res.error, "error");
|
||||
await loadPersonas();
|
||||
if (persona.persona_type === "assistant") await loadChatSessions();
|
||||
setNotice("Persona deleted.", "success");
|
||||
}
|
||||
|
||||
async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); }
|
||||
|
||||
async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); }
|
||||
|
||||
async function startRoleplay() {
|
||||
if (state.roleplayStarterStep === 1) {
|
||||
goToRoleplayStarterSceneStep();
|
||||
return;
|
||||
}
|
||||
setChatLoading(true, "Starting roleplay...");
|
||||
setChatActivity(true, {
|
||||
eyebrow: "Starting roleplay",
|
||||
title: "Building the opening scene",
|
||||
detail: "Generating the first beat and opening the roleplay chat."
|
||||
});
|
||||
const assistantPersonaId = Number(roleplayCharacterSelect.value);
|
||||
const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null;
|
||||
const scenarioPrompt = roleplayScenarioInput?.value.trim() || "";
|
||||
if (!assistantPersonaId) {
|
||||
setChatLoading(false);
|
||||
setChatActivity(false);
|
||||
return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error");
|
||||
}
|
||||
roleplayStarterConfirm.disabled = true;
|
||||
setRoleplayStarterNotice("Building the opening scene...");
|
||||
const res = await post("/roleplays/start", {
|
||||
assistantPersonaId,
|
||||
userPersonaId,
|
||||
scenarioPrompt,
|
||||
model: modelSelect.value
|
||||
});
|
||||
roleplayStarterConfirm.disabled = false;
|
||||
if (res.error || !res.chat) {
|
||||
setChatLoading(false);
|
||||
setChatActivity(false);
|
||||
return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error");
|
||||
}
|
||||
closeRoleplayStarter();
|
||||
await loadChatSessions();
|
||||
await setActiveChat(res.chat.id);
|
||||
setNotice(
|
||||
res.degraded
|
||||
? "Roleplay started, but the opening scene timed out. Send the first message to continue."
|
||||
: res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.",
|
||||
res.degraded ? "error" : "success"
|
||||
);
|
||||
setChatLoading(false);
|
||||
setChatActivity(false);
|
||||
}
|
||||
|
||||
return {
|
||||
updateRoleplaySuggestions,
|
||||
setRoleplayStarterNotice,
|
||||
renderRoleplayStarterStep,
|
||||
maybeShowPersonaNudge,
|
||||
openPersonaForm,
|
||||
closePersonaForm,
|
||||
openRoleplayStarter,
|
||||
closeRoleplayStarter,
|
||||
loadPersonas,
|
||||
clearPersona,
|
||||
savePersona,
|
||||
startRoleplay
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import {stream} from "./api.js";
|
||||
import {updateReasoning, finishReasoning} from "./reasoning.js";
|
||||
import {appendSources} from "./webSearch.js";
|
||||
import {createStreamRenderer} from "./streamRenderer.js";
|
||||
|
||||
// Keep the original nodes (including action handlers) until a committed answer arrives.
|
||||
export async function regenerateInline({card, scroller, endpoint, payload, renderContent, complete}) {
|
||||
@@ -25,8 +26,17 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende
|
||||
// Anchor to this response, including when a long old answer collapses.
|
||||
update(() => card.replaceChildren(original[0].cloneNode(true), status, body));
|
||||
card.setAttribute("aria-busy", "true");
|
||||
const rendering = createStreamRenderer({
|
||||
onThinking: text => update(() => updateReasoning(card, text, true)),
|
||||
onReply: text => update(() => {
|
||||
status.hidden = Boolean(text);
|
||||
if (text) finishReasoning(card);
|
||||
renderContent(body, text);
|
||||
})
|
||||
});
|
||||
try {
|
||||
await stream(endpoint, payload, {
|
||||
...rendering,
|
||||
onStatus: data => update(() => {
|
||||
status.textContent = data.status === "searching" ? "Searching the web..."
|
||||
: data.status === "analyzing" ? "Analyzing attachments..." : "Generating...";
|
||||
@@ -35,20 +45,15 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende
|
||||
appendSources(card, data.sources);
|
||||
}
|
||||
}),
|
||||
onThinking: ({fullThinking}) => update(() => updateReasoning(card, fullThinking, true)),
|
||||
onChunk: ({fullReply}) => update(() => {
|
||||
status.hidden = Boolean(fullReply);
|
||||
if (fullReply) finishReasoning(card);
|
||||
renderContent(body, fullReply);
|
||||
}),
|
||||
onError: ({error}) => { throw new Error(error || "Unable to regenerate message."); },
|
||||
onDone: ({message}) => {
|
||||
if (!message) throw new Error("Missing regenerated response.");
|
||||
rendering.dispose();
|
||||
update(() => complete(message));
|
||||
committed = true;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
rendering.dispose();
|
||||
// Bei Fehler oder Abbruch die ursprünglichen Knoten samt Ereignishandlern wieder einsetzen.
|
||||
if (!committed) update(() => card.replaceChildren(...original));
|
||||
card.removeAttribute("aria-busy");
|
||||
|
||||
+207
-1089
File diff suppressed because it is too large
Load Diff
@@ -316,7 +316,7 @@ async function loadProfile() {
|
||||
if (accountName) { accountName.textContent = res.username || ""; accountName.title = res.username || ""; }
|
||||
currentUsernameInput.value = res.username || "";
|
||||
newUsernameInput.value = res.username || "";
|
||||
isAdminUser = res.username === "admin";
|
||||
isAdminUser = res.role === "admin";
|
||||
diagnosticsNavItem.classList.toggle("hidden", !isAdminUser);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Accumulate deltas and render at most once per animation frame. */
|
||||
export function createStreamRenderer({onReply, onThinking, schedule = callback => requestAnimationFrame(callback), cancel = id => cancelAnimationFrame(id)}) {
|
||||
const text = {reply: "", thinking: ""};
|
||||
const dirty = new Set();
|
||||
let frame = null;
|
||||
const flush = () => {
|
||||
if (frame !== null) cancel(frame);
|
||||
frame = null;
|
||||
if (dirty.has("thinking")) onThinking?.(text.thinking);
|
||||
if (dirty.has("reply")) onReply?.(text.reply);
|
||||
dirty.clear();
|
||||
};
|
||||
const update = (target, value, replace = false) => {
|
||||
text[target] = replace ? value : text[target] + value;
|
||||
dirty.add(target);
|
||||
if (frame === null) frame = schedule(flush);
|
||||
};
|
||||
return {
|
||||
onChunk: ({chunk, fullReply}) => update("reply", typeof fullReply === "string" ? fullReply : chunk, typeof fullReply === "string"),
|
||||
onThinking: ({chunk, fullThinking}) => update("thinking", typeof fullThinking === "string" ? fullThinking : chunk, typeof fullThinking === "string"),
|
||||
onReplace: ({target, text: value}) => update(target, value, true),
|
||||
flush,
|
||||
dispose() {
|
||||
if (frame !== null) cancel(frame);
|
||||
frame = null;
|
||||
dirty.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user