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
+149 -75
View File
@@ -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();
}
}
}
+9
View File
@@ -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);
+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
};
}
+184
View File
@@ -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
};
}
+28
View File
@@ -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
};
}
+342
View File
@@ -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
};
}
+422
View File
@@ -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
};
}
+12 -7
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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;
}
+29
View File
@@ -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();
}
};
}