Main #15

Merged
Fretux merged 14 commits from main into dev 2026-10-12 08:59:14 +02:00
38 changed files with 4897 additions and 1257 deletions
Showing only changes of commit c48e1b17c4 - Show all commits
+2 -1
View File
@@ -2,7 +2,8 @@ SESSION_SECRET=replace-with-a-long-random-secret
# Optional: reachable from the Express server, never from the browser.
# SEARXNG_URL=http://searxng:8080
WEB_SEARCH_TIMEOUT_MS=8000
UPLOAD_DIR=/app/data/uploads
# Production containers can set UPLOAD_DIR=/app/data/uploads.
# Locally, omitting UPLOAD_DIR uses data/uploads.
UPLOAD_MAX_SIZE_MB=10
UPLOAD_MAX_FILES=5
UPLOAD_MAX_PIXELS=30000000
+19
View File
@@ -0,0 +1,19 @@
name: CI
on: [push, pull_request]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
+11 -1
View File
@@ -1,2 +1,12 @@
node_modules/
data/uploads/
.env
.env.*
!.env.example
data/
sessions/
*.db
*.db-*
*.sqlite
*.sqlite-*
*.sqlite3
*.sqlite3-*
+58 -5
View File
@@ -6,18 +6,51 @@ A space for every conversation. Six appearance styles (Grove, Parchment, Aurora,
## Setup
Node.js 22 or newer is required.
Node.js 22.13 or newer is required.
1. Install dependencies with `npm install`.
Native dependencies also need a working build toolchain. On Windows, install Python
and Visual Studio's **Desktop development with C++** workload before `npm ci`.
On Linux, install Python, `make` and a C++ compiler; on macOS, use Xcode Command Line
Tools. See the [node-gyp prerequisites](https://github.com/nodejs/node-gyp#installation).
1. Install the locked dependencies with `npm ci`.
2. Copy `.env.example` to `.env` and set at least `SESSION_SECRET`.
3. Start the app with `npm run dev` for local development or `npm start` for a normal server run.
Both startup scripts load `.env` when present; environment variables already set by the
host take precedence. `.env`, databases, uploads and session files are ignored by Git.
Use `npm install` when deliberately updating dependencies and include both
`package.json` and `package-lock.json` in the change. CI runs `npm ci`, lint and tests
on Node 22 and 24.
## Scripts
- `npm start`: run the production server entrypoint.
- `npm run dev`: run the server with Node watch mode.
- `npm run lint`: syntax-check the backend and browser modules.
- `npm test`: run API tests with Node's built-in test runner.
- `npm run user:role -- <username> <user|admin>`: assign or revoke a role from the server console.
## Administrative access
Accounts have a server-managed `users.role`, constrained to `user` or `admin`. Registration
and migrations default to `user`, including accounts named `admin`. Usernames, profile
updates and workspace imports never grant roles. Existing installations must explicitly
promote their intended administrator after deploying this migration:
```sh
npm run user:role -- your-existing-username admin
```
Run this on the server with the application's `DB_PATH` and environment. To revoke
access, run the same command with `user`. Authorization reads the database role on each
request, so promotion and revocation apply to existing sessions; renaming retains the role.
There is no public role assignment endpoint.
Async route handlers use `asyncHandler` to forward rejected promises to Express 4's
error middleware, following [Express's error handling guidance](https://expressjs.com/en/4x/guide/error-handling/).
Duplicate registrations, including concurrent inserts, return HTTP 409 with
`code: "USERNAME_TAKEN"`.
## Environment Variables
@@ -196,7 +229,7 @@ The server is configured around an Ollama-compatible API surface:
`qwen3:14b` is the default model for chat and roleplay. The model selector loads available models from `/tags` and selects `qwen3:14b` when available (otherwise the first returned model). Existing chats retain their saved model selection.
The composer has independent Web Search and Reasoning toggles, both off by default and reset on server acceptance. It sends `{message, chatId, model, webSearch, reasoning}` using streamed fetch to `POST /chat/stream`. Existing SSE events carry `status` (accepted/searching/generating), `thinking` (chunk/fullThinking), `chunk` (chunk/fullReply), `done` (reply/sources), and `error`. Final answers stream incrementally, including persona responses. Persona validation still runs; a corrective pass resets provisional output before streaming its replacement. Only the validated complete answer is persisted.
The composer has independent Web Search and Reasoning toggles, both off by default and reset after a completed reply. It sends `{message, chatId, model, webSearch, reasoning}` using streamed fetch to `POST /chat/stream`. SSE events carry `status` (accepted/analyzing/searching/generating), `thinking` (`{chunk}`), `chunk` (`{chunk}`), `replace` (`{target: "reply"|"thinking", text}`), `done` (reply/sources), and `error` (error/status/code/requestId when available). Text events send only incremental text. A corrective persona pass explicitly replaces provisional output before streaming its replacement. Both sends and regenerations batch Markdown rendering per animation frame. Only the validated complete answer is persisted. Clients consuming SSE must handle `replace` events and accumulate deltas; the browser also accepts the older accumulated event format.
Every Ollama request explicitly sets `stream: true`, `think`, and `keep_alive`. Reasoning is requested and forwarded only when `reasoning === true` and the model supports the boolean thinking API. The shared conservative capability check supports Qwen3, DeepSeek R1 and DeepSeek v3.1 model IDs; unknown models, Qwen coder variants and models requiring thinking levels have the toggle disabled. Internal calls, roleplay openers and regeneration default to `think: false`. There is no extra model call to obtain reasoning.
@@ -230,7 +263,12 @@ Legacy `chats` migration is handled during startup. New code only reads the sess
The browser UI remains framework-free and uses native ES modules. Shared behavior lives under `public/js/`:
- `api.js`: JSON and streaming request helpers
- `api.js`: JSON and streaming request helpers with HTTP metadata, cancellation and deadlines
- `conversationState.js` / `conversationController.js`: shared state, chat selection, creation, search and drafts
- `messageRenderer.js`: sanitized Markdown, message cards, actions and scroll position
- `composerController.js`: immediate send locking, captured chat/model, Stop and send recovery
- `personaController.js`: persona forms, library actions and roleplay setup
- `streamRenderer.js`: delta accumulation, replacement events and batched rendering
- `constants.js`: theme and onboarding metadata
- `themeController.js`: validated theme/mode persistence and application
- `theme-bootstrap.js`: applies stored appearance before page initialization
@@ -238,6 +276,21 @@ The browser UI remains framework-free and uses native ES modules. Shared behavio
- `statusNotice.js`: shared status and error presentation
- `dom.js`: cached chat-page DOM references
JSON helpers retain the existing `{error}` result convention and add `status`, `code`
and `requestId` on failure. Successful empty responses return `{}`. Pass
`{signal, timeoutMs}` as the last argument to `get`, `post`, `put`, `del`, or `request`;
the default deadline is 30 seconds. `stream` accepts the same options (or the legacy
AbortSignal argument), defaults to 660 seconds, and rejects with `ApiError` unless
`onError` handles the failure. A `timeoutMs` of 0 disables the deadline. Cancellation,
timeout, network and malformed-response failures have distinct codes. HTTP errors
keep their status even if the response is HTML or invalid JSON.
DOMPurify is an npm production dependency pinned in the lockfile and served only at
`/vendor/dompurify.js`; `node_modules` is not a static directory. The former CDN version
3.0.6 was affected by the maintainer's [mXSS advisory](https://github.com/cure53/DOMPurify/security/advisories/GHSA-gx9m-whjm-85jf).
The installed version is 3.4.16. Update it through npm so audits and reproducible installs
also cover the browser sanitizer.
`public/css/style.css` is a self-contained design system rebuilt around semantic tokens, shared components, and deliberate responsive breakpoints. It contains the six visual identities and their dark variants without legacy imports or precedence layers. `public/css/legal.css` contains only the legal-page layout.
Pure chat message, retry, memory, summary, and persona-state formatting lives in `src/services/chatMessageHelpers.js`; `chatService.js` remains responsible for orchestration and persistence.
@@ -250,7 +303,7 @@ Search uses `shouldSearch = webSearchEnabled && (explicitSearchIntent || automat
The chat service retrieves up to 15 candidates per query and selects 1–5 useful sources dynamically, rejecting empty evidence and wrong-date results. SearXNG `content` maps to `snippet` (an existing non-empty snippet is also accepted). Exact-date event searches use the general index to include archives and try at most one alternate query if the first returns no usable evidence; candidates are merged and deduplicated. Current-news requests retain the news category. The same numbered selected titles, URLs and snippets go immediately before the current question in Ollama's context, into the response, and into storage. Retrieval finishes before either reasoning or answer streaming starts. Instructions require citations, supported partial answers rather than blanket claims of missing details, and honest disclosure when evidence is insufficient. Result content remains untrusted data; no result pages are fetched.
Set environment variables in the server/container environment, or load the example explicitly with `node --env-file=.env server.js` (the existing npm start command does not load `.env` automatically):
Set environment variables in the server/container environment, or in `.env` (loaded by `npm start` and `npm run dev`):
```dotenv
SEARXNG_URL=http://searxng:8080
+4
View File
@@ -7,4 +7,8 @@ import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: {...globals.browser, ...globals.node} } },
{
files: ["public/js/script.js", "public/js/composerController.js", "public/js/conversationController.js", "public/js/messageRenderer.js", "public/js/personaController.js"],
rules: {"no-shadow": "error"}
},
]);
+2756
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -4,11 +4,12 @@
"type": "module",
"main": "server.js",
"engines": {
"node": ">=22"
"node": ">=22.13.0"
},
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"start": "node --env-file-if-exists=.env server.js",
"dev": "node --env-file-if-exists=.env --watch server.js",
"user:role": "node --env-file-if-exists=.env scripts/set-role.js",
"lint": "eslint .",
"test": "node --test"
},
@@ -16,6 +17,7 @@
"bcrypt": "^6.0.0",
"better-sqlite3": "^13.0.3",
"body-parser": "^1.20.2",
"dompurify": "3.4.16",
"express": "^4.19.2",
"express-session": "^1.17.3",
"multer": "^2.4.0",
+1 -1
View File
@@ -469,7 +469,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.12/marked.min.js"
integrity="sha512-rCQgmUulW6f6QegOvTntKKb5IAoxTpGVCdWqYjkXEpzAns6XUFs8NKVqWe+KQpctp/EoRSFSuykVputqknLYMg=="
crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
<script src="/vendor/dompurify.js" defer></script>
<script type="module" src="/js/script.js?v=20260930-chat-usability"></script>
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
</body>
+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();
}
};
}
+18
View File
@@ -0,0 +1,18 @@
/** Operator-only role assignment; never exposed through an HTTP endpoint. */
import {createConfig} from "../src/config.js";
import {createDatabase} from "../src/db/index.js";
const [username, role] = process.argv.slice(2);
if (!username || !["user", "admin"].includes(role) || process.argv.length !== 4) {
console.error("Usage: npm run user:role -- <existing-username> <user|admin>");
process.exitCode = 1;
} else {
const db = createDatabase(createConfig());
try {
const result = db.prepare("UPDATE users SET role = ? WHERE username = ?").run(role, username);
if (!result.changes) throw new Error("Account not found. Register the account first.");
console.log(`Role for ${username} set to ${role}.`);
} finally {
db.close();
}
}
+12 -1
View File
@@ -2,6 +2,7 @@
* @file Verbindet Express, Sitzungen, Datenbank und Routen und verwaltet den Server-Lebenszyklus.
*/
import fs from "fs";
import {createRequire} from "node:module";
import {randomUUID} from "crypto";
import {performance} from "perf_hooks";
import express from "express";
@@ -225,7 +226,11 @@ function enforceSessionVersion(repositories) {
const user = repositories.getUser(req.session.user);
const currentVersion = Number(user?.session_version || 0);
const sessionVersion = Number(req.session.sessionVersion || 0);
if (user && currentVersion === sessionVersion) return next();
if (user && currentVersion === sessionVersion) {
// Read the role on every request so revocation also affects existing sessions.
req.account = user;
return next();
}
req.session.destroy(() => {
if (req.path === "/session") {
@@ -296,6 +301,12 @@ export function createApp(options = {}) {
app.locals.close = createCloseHandler(closeHandlers);
app.use(createPagesRouter(config));
const require = createRequire(import.meta.url);
const dompurifyPath = require.resolve("dompurify").replace(/purify\.cjs\.js$/, "purify.min.js");
app.get("/vendor/dompurify.js", (_req, res) => {
res.set("Cache-Control", "no-cache");
res.sendFile(dompurifyPath);
});
app.use(express.static(config.publicDir, {
maxAge: config.staticMaxAge,
etag: config.staticEtag,
+1 -1
View File
@@ -46,7 +46,7 @@ function normalizeMarketPersonaType(personaType) {
export function createRepositories(db, config) {
const statements = {
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
getUser: db.prepare("SELECT username, password, session_version FROM users WHERE username = ?"),
getUser: db.prepare("SELECT username, password, role, session_version FROM users WHERE username = ?"),
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
+2
View File
@@ -10,6 +10,7 @@ export function initializeSchema(db) {
(
username TEXT PRIMARY KEY,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
session_version INTEGER DEFAULT 0
)
`).run();
@@ -256,6 +257,7 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
};
const userColumns = db.prepare("PRAGMA table_info(users)").all();
ensureColumn("users", userColumns, "role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'))");
ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0");
db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run();
+3
View File
@@ -0,0 +1,3 @@
/** Forward rejected route promises to Express 4's error middleware. */
export const asyncHandler = (handler) => (req, res, next) =>
Promise.resolve().then(() => handler(req, res, next)).catch(next);
+7
View File
@@ -7,3 +7,10 @@ export function requireLogin(req, res, next) {
}
return next();
}
export function requireAdmin(req, res, next) {
if (req.account?.role !== "admin") {
return res.status(403).json({error: "Admin only"});
}
return next();
}
+17 -8
View File
@@ -2,6 +2,7 @@
* @file Verarbeitet Registrierung, Anmeldung, Abmeldung und die Abfrage der aktuellen Sitzung.
*/
import express from "express";
import {asyncHandler} from "../middleware/asyncHandler.js";
import bcrypt from "bcrypt";
import {validateBody} from "../middleware/validate.js";
@@ -19,23 +20,31 @@ const credentialsValidator = (body) => {
export function createAuthRouter({repositories, authRateLimiters, config}) {
const router = express.Router();
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
const {username, password, inviteCode} = req.validatedBody;
if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
return res.status(403).json({error: "Valid invite code is required"});
}
if (repositories.getUser(username)) {
return res.status(400).json({error: "User already exists"});
return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"});
}
const hashed = await bcrypt.hash(password, 10);
repositories.insertUser(username, hashed);
try {
repositories.insertUser(username, hashed);
} catch (error) {
// The precheck can race while bcrypt yields; the database is authoritative.
if (["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE"].includes(error.code)) {
return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"});
}
throw error;
}
req.session.user = username;
req.session.sessionVersion = 0;
return res.json({message: "Registration successful"});
});
}));
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
const {username, password} = req.validatedBody;
const user = repositories.getUser(username);
if (!user) return res.status(400).json({error: "User not found"});
@@ -44,10 +53,10 @@ export function createAuthRouter({repositories, authRateLimiters, config}) {
req.session.user = username;
req.session.sessionVersion = Number(user.session_version || 0);
return res.json({message: "Login successful"});
});
}));
router.post("/logout", (req, res) => {
req.session.destroy(() => res.json({message: "Logged out"}));
router.post("/logout", (req, res, next) => {
req.session.destroy(error => error ? next(error) : res.json({message: "Logged out"}));
});
router.get("/session", (req, res) => {
+24 -24
View File
@@ -2,6 +2,8 @@
* @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit.
*/
import express from "express";
import {createTextStreamEvents} from "../services/streamEvents.js";
import {asyncHandler} from "../middleware/asyncHandler.js";
import multer from "multer";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
@@ -139,14 +141,14 @@ export function createChatsRouter({repositories, chatService, modelService, conf
next();
});
router.get("/models", async (req, res) => {
router.get("/models", asyncHandler(async (req, res) => {
try {
return res.json(await modelService.listModels());
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
}));
router.get("/chats", (req, res) => res.json({chats: repositories.listChats(req.session.user)}));
router.get("/chats/search", (req, res) => res.json({chats: repositories.searchChats(req.session.user, req.query.q || "")}));
@@ -262,7 +264,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json(result);
});
router.post("/chats/:id/messages/:messageId/edit-resend", validateBody(sendMessageValidator), async (req, res) => {
router.post("/chats/:id/messages/:messageId/edit-resend", validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
if (req.body?.confirmed !== true) return res.status(400).json({error: "Confirmation is required before creating an edited branch"});
const chatId = Number(req.params.id);
const targetMessage = repositories.getChatMessage(chatId, Number(req.params.messageId));
@@ -285,7 +287,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
}));
router.put("/chats/:id/messages/:messageId", validateBody(messageValidator), (req, res) => {
const chatId = Number(req.params.id);
@@ -446,7 +448,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json({state});
});
router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
try {
const result = await chatService.sendChatMessage({
user: req.session.user,
@@ -464,9 +466,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
}));
router.post("/chat/stream", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
router.post("/chat/stream", ...chatRateLimiters, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
const generationController = new AbortController();
let generationFinished = false;
res.once("close", () => {
@@ -493,26 +495,25 @@ export function createChatsRouter({repositories, chatService, modelService, conf
message: req.validatedBody.message,
attachments: req.validatedBody.attachments,
model: req.validatedBody.model,
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}),
onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}),
...createTextStreamEvents(sendEvent),
webSearch: req.validatedBody.webSearch,
reasoning: req.validatedBody.reasoning,
onStatus: (status) => sendEvent("status", status),
signal: generationController.signal
});
if (result.error) {
sendEvent("error", {error: result.error, ...(result.searchState ? {searchState: result.searchState} : {})});
sendEvent("error", {error: result.error, status: result.status, requestId: req.id, ...(result.searchState ? {searchState: result.searchState} : {})});
} else {
sendEvent("done", {reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {})});
}
} catch (error) {
const mapped = modelService.mapError(error);
sendEvent("error", mapped.body);
sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id});
} finally {
generationFinished = true;
res.end();
}
});
}));
router.post("/chats/:id/messages/:messageId/versions/:index", (req, res) => {
const chatId = Number(req.params.id), messageId = Number(req.params.messageId);
@@ -528,7 +529,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))});
});
router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, async (req, res) => {
router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, asyncHandler(async (req, res) => {
const chatId = Number(req.params.id);
const messageId = Number(req.params.messageId);
const chat = repositories.getChat(chatId, req.session.user);
@@ -559,28 +560,27 @@ export function createChatsRouter({repositories, chatService, modelService, conf
reasoning: req.body?.reasoning === true,
signal: controller.signal,
...(streaming ? {
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}),
onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}),
...createTextStreamEvents(sendEvent),
onStatus: status => sendEvent("status", status)
} : {})
});
if (streaming) {
sendEvent(result.error ? "error" : "done", result.error ? {error: result.error, searchState: result.searchState} : {...result, reply: result.message.content});
sendEvent(result.error ? "error" : "done", result.error ? {error: result.error, status: result.status, requestId: req.id, searchState: result.searchState} : {...result, reply: result.message.content});
return;
}
if (result.error) return res.status(result.status).json({error: result.error});
return res.json(result);
} catch (error) {
const mapped = modelService.mapError(error);
if (streaming) { sendEvent("error", mapped.body); return; }
if (streaming) { sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id}); return; }
return res.status(mapped.status).json(mapped.body);
} finally {
finished = true;
if (streaming) res.end();
}
});
}));
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, async (req, res) => {
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, asyncHandler(async (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
@@ -605,7 +605,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
}));
router.get("/exports/chats/:id", (req, res) => {
const chatId = Number(req.params.id);
@@ -627,7 +627,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
});
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
router.get("/exports/workspace/archive", async (req, res) => {
router.get("/exports/workspace/archive", asyncHandler(async (req, res) => {
try {
const workspace = repositories.exportWorkspace(req.session.user);
const images = new Map();
@@ -642,7 +642,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
} catch (error) {
return res.status(error.message.includes("250 MB") ? 413 : 500).json({error: "Workspace archive could not be created"});
}
});
}));
router.post("/imports/workspace/archive/preview", readArchive, (req, res) => {
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
if (validation.ok) {
@@ -651,7 +651,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
}
return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview});
});
router.post("/imports/workspace/archive", readArchive, async (req, res) => {
router.post("/imports/workspace/archive", readArchive, asyncHandler(async (req, res) => {
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors});
if (validation.preview.duplicates.length && req.body?.allowDuplicates !== "true") {
@@ -677,7 +677,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
for (const id of restored) await attachmentService.remove(id, req.session.user).catch(() => {});
return res.status(error.status || 400).json({error: error.message || "Workspace archive import failed"});
}
});
}));
router.post("/imports/workspace/preview", (req, res) => {
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
return res.status(validation.ok ? 200 : 400).json({
+6 -5
View File
@@ -2,6 +2,7 @@
* @file Verwaltet eigene Personas sowie Veröffentlichung, Suche und Interaktionen auf dem Persona-Marktplatz.
*/
import express from "express";
import {asyncHandler} from "../middleware/asyncHandler.js";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
import {normalizeOptionalText, validatePersonaPayload} from "../services/personaService.js";
@@ -49,7 +50,7 @@ const templateValidator = (body) => {
export function createPersonasRouter({repositories, chatService, modelService, config}) {
const router = express.Router();
router.use(requireLogin);
const isAdmin = (req) => req.session.user === "admin";
const isAdmin = (req) => req.account?.role === "admin";
const parseMarketPersonaType = (value) => ["assistant", "user"].includes(value) ? value : null;
router.get("/personas", (req, res) => {
@@ -214,7 +215,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
return res.json({message: "Report submitted"});
});
router.post("/personas/market/:id/chat", async (req, res) => {
router.post("/personas/market/:id/chat", asyncHandler(async (req, res) => {
const user = req.session.user;
const marketPersona = repositories.getMarketPersona(Number(req.params.id));
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
@@ -281,9 +282,9 @@ export function createPersonasRouter({repositories, chatService, modelService, c
} catch {
return res.json({persona, chat, existing: false, generatedInitialMessage: false, degraded: true});
}
});
}));
router.post("/roleplays/start", validateBody(roleplayStartValidator), async (req, res) => {
router.post("/roleplays/start", validateBody(roleplayStartValidator), asyncHandler(async (req, res) => {
const user = req.session.user;
const {assistantPersonaId, userPersonaId, scenarioPrompt, model} = req.validatedBody;
const assistantPersona = repositories.getPersona(assistantPersonaId, user);
@@ -345,7 +346,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
modelErrorCode: mapped.body.code
});
}
});
}));
router.get("/templates", (req, res) => {
const personaId = req.query.personaId ? Number(req.query.personaId) : null;
+20 -12
View File
@@ -2,6 +2,7 @@
* @file Verarbeitet Benutzer- und Kontoeinstellungen sowie Export und Import des Arbeitsbereichs.
*/
import express from "express";
import {asyncHandler} from "../middleware/asyncHandler.js";
import bcrypt from "bcrypt";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
@@ -29,7 +30,7 @@ const currentPasswordValidator = (body) => {
return {value: {currentPassword}};
};
const destroySession = (req) => new Promise((resolve) => req.session.destroy(() => resolve()));
const destroySession = (req) => new Promise((resolve, reject) => req.session.destroy(error => error ? reject(error) : resolve()));
export function createSettingsRouter({repositories}) {
const router = express.Router();
@@ -38,10 +39,10 @@ export function createSettingsRouter({repositories}) {
router.get("/settings/profile", (req, res) => {
const user = repositories.getUser(req.session.user);
if (!user) return res.status(404).json({error: "User not found"});
return res.json({username: user.username});
return res.json({username: user.username, role: user.role});
});
router.put("/settings/username", validateBody(usernameValidator), async (req, res) => {
router.put("/settings/username", validateBody(usernameValidator), asyncHandler(async (req, res) => {
const currentUsername = req.session.user;
const {username: nextUsername, password} = req.validatedBody;
if (nextUsername === currentUsername) {
@@ -53,15 +54,22 @@ export function createSettingsRouter({repositories}) {
const valid = await bcrypt.compare(password, currentUser.password);
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
if (repositories.getUser(nextUsername)) {
return res.status(400).json({error: "Username already exists"});
return res.status(409).json({error: "Username already exists", code: "USERNAME_TAKEN"});
}
repositories.renameUser(currentUsername, nextUsername);
try {
repositories.renameUser(currentUsername, nextUsername);
} catch (error) {
if (["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE"].includes(error.code)) {
return res.status(409).json({error: "Username already exists", code: "USERNAME_TAKEN"});
}
throw error;
}
req.session.user = nextUsername;
return res.json({username: nextUsername});
});
}));
router.put("/settings/password", validateBody(passwordValidator), async (req, res) => {
router.put("/settings/password", validateBody(passwordValidator), asyncHandler(async (req, res) => {
const username = req.session.user;
const {currentPassword, newPassword} = req.validatedBody;
const user = repositories.getUser(username);
@@ -73,11 +81,11 @@ export function createSettingsRouter({repositories}) {
const updatedUser = repositories.incrementSessionVersion(username);
req.session.sessionVersion = Number(updatedUser.session_version || 0);
return res.json({message: "Password updated"});
});
}));
router.post("/settings/logout-all-sessions", (req, res) => {
router.post("/settings/logout-all-sessions", (req, res, next) => {
repositories.incrementSessionVersion(req.session.user);
req.session.destroy(() => res.json({message: "All sessions logged out"}));
req.session.destroy(error => error ? next(error) : res.json({message: "All sessions logged out"}));
});
router.get("/settings/export", (req, res) => {
@@ -92,7 +100,7 @@ export function createSettingsRouter({repositories}) {
});
});
router.delete("/settings/account", validateBody(currentPasswordValidator), async (req, res) => {
router.delete("/settings/account", validateBody(currentPasswordValidator), asyncHandler(async (req, res) => {
const username = req.session.user;
const user = repositories.getUser(username);
if (!user) return res.status(404).json({error: "User not found"});
@@ -101,7 +109,7 @@ export function createSettingsRouter({repositories}) {
repositories.deleteUser(username);
await destroySession(req);
return res.json({message: "Account deleted"});
});
}));
return router;
}
+6 -12
View File
@@ -2,14 +2,8 @@
* @file Stellt Modellinformationen und administrative Diagnosedaten bereit.
*/
import express from "express";
import {requireLogin} from "../middleware/auth.js";
function requireAdmin(req, res, next) {
if (req.session.user !== "admin") {
return res.status(403).json({error: "Admin only"});
}
return next();
}
import {asyncHandler} from "../middleware/asyncHandler.js";
import {requireLogin, requireAdmin} from "../middleware/auth.js";
function buildConfigWarnings(config) {
const warnings = [];
@@ -34,18 +28,18 @@ function buildConfigWarnings(config) {
export function createSystemRouter({modelService, config}) {
const router = express.Router();
router.get("/health", async (req, res) => {
router.get("/health", asyncHandler(async (req, res) => {
const model = await modelService.checkHealth();
return res.status(model.ok ? 200 : 503).json({ok: model.ok, model});
});
}));
router.get("/admin/diagnostics", requireLogin, requireAdmin, async (req, res) => {
router.get("/admin/diagnostics", requireLogin, requireAdmin, asyncHandler(async (req, res) => {
const diagnostics = await modelService.getDiagnostics();
return res.status(diagnostics.model.ok ? 200 : 503).json({
model: diagnostics.model,
configWarnings: buildConfigWarnings(config)
});
});
}));
return router;
}
+5 -4
View File
@@ -2,6 +2,7 @@
* @file Verarbeitet das Hochladen, Abrufen und Entfernen von Bildanhängen mit Zugriffskontrolle.
*/
import express from "express";
import {asyncHandler} from "../middleware/asyncHandler.js";
import multer from "multer";
import {requireLogin} from "../middleware/auth.js";
import {AttachmentError} from "../services/attachmentService.js";
@@ -31,17 +32,17 @@ export function createUploadsRouter(service, config) {
catch (failure) { fail(res, failure); }
});
});
router.get("/attachments/:id", requireLogin, async (req, res) => {
router.get("/attachments/:id", requireLogin, asyncHandler(async (req, res) => {
try {
const row = service.owned(req.params.id, req.session.user);
const buffer = await service.storage.read(row.storage_key);
res.set({"Content-Type": row.mime_type, "Content-Length": buffer.length, "Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff", "Content-Disposition": "inline"}).send(buffer);
} catch (error) { fail(res, error); }
});
router.delete("/attachments/:id", requireLogin, async (req, res) => {
}));
router.delete("/attachments/:id", requireLogin, asyncHandler(async (req, res) => {
try { await service.remove(req.params.id, req.session.user); res.json({success: true}); }
catch (error) { fail(res, error); }
});
}));
return router;
}
+8
View File
@@ -0,0 +1,8 @@
/** Translate model callbacks to small wire events, including corrective-pass resets. */
export function createTextStreamEvents(sendEvent) {
const emit = (event, target) => (chunk, fullText) => {
if (chunk === "" && fullText === "") sendEvent("replace", {target, text: ""});
else if (chunk) sendEvent(event, {chunk});
};
return {onChunk: emit("chunk", "reply"), onThinking: emit("thinking", "thinking")};
}
+71
View File
@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import {ApiError, get, post, stream} from "../public/js/api.js";
test("JSON requests retain HTTP metadata, handle empty success, and distinguish bad bodies from network errors", async t => {
let response;
t.mock.method(globalThis, "fetch", async () => response);
for (const status of [204, 205, 200]) {
response = new Response(null, {status});
assert.deepEqual(await get("/empty"), {});
}
response = new Response("<html>Proxy unavailable</html>", {status: 503, statusText: "Service Unavailable", headers: {"X-Request-ID": "proxy-123"}});
assert.deepEqual(await get("/proxy"), {error: "HTTP 503 Service Unavailable", status: 503, code: "HTTP_503", requestId: "proxy-123"});
response = Response.json({error: "Already exists", code: "USERNAME_TAKEN", status: 400, requestId: "body-123", retryAfter: 5}, {status: 409});
assert.deepEqual(await post("/register", {}), {error: "Already exists", code: "USERNAME_TAKEN", status: 409, requestId: "body-123", retryAfter: 5});
response = Response.json({error: {message: "Forbidden", code: "DENIED"}}, {status: 403});
assert.equal((await get("/private")).code, "DENIED");
response = new Response("not json", {headers: {"X-Request-ID": "invalid-body"}});
assert.deepEqual(await get("/invalid"), {error: "Invalid JSON response.", status: 200, code: "INVALID_RESPONSE", requestId: "invalid-body"});
t.mock.method(globalThis, "fetch", async () => { throw new TypeError("Failed to fetch"); });
assert.equal((await get("/offline")).code, "NETWORK_ERROR");
});
test("JSON requests support caller abort and deadlines across headers and body reads", async t => {
let signal;
t.mock.method(globalThis, "fetch", async (_url, options) => {
signal = options.signal;
return new Promise(() => {});
});
const controller = new AbortController();
const cancelled = get("/cancel", {signal: controller.signal});
controller.abort();
assert.equal((await cancelled).code, "ABORTED");
assert.equal((await get("/timeout", {timeoutMs: 10})).code, "TIMEOUT");
assert.equal(signal.aborted, true);
t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream(), {headers: {"X-Request-ID": "slow-body"}}));
const bodyTimeout = await get("/slow-body", {timeoutMs: 10});
assert.equal(bodyTimeout.code, "TIMEOUT");
assert.equal(bodyTimeout.status, 200);
assert.equal(bodyTimeout.requestId, "slow-body");
});
test("SSE errors reject by default and deliver complete metadata to an explicit handler", async t => {
const payload = {error: "Model unavailable", status: 503, code: "MODEL_UNAVAILABLE", requestId: "model-123"};
t.mock.method(globalThis, "fetch", async () => new Response(`event: error\ndata: ${JSON.stringify(payload)}\n\n`));
await assert.rejects(stream("/stream", {}), error => error instanceof ApiError && error.status === 503 && error.code === payload.code && error.requestId === payload.requestId);
let handled;
await stream("/stream", {}, {onError: error => { handled = error; }});
assert.deepEqual(handled, payload);
t.mock.method(globalThis, "fetch", async () => new Response("upstream unavailable", {status: 503, headers: {"X-Request-ID": "proxy-123"}}));
await assert.rejects(stream("/stream", {}), error => error.status === 503 && error.code === "HTTP_503" && error.requestId === "proxy-123");
});
test("SSE supports fragmented UTF-8, multiline data, replacements, cancellation and stalled reads", async t => {
const received = [];
const wire = 'event: chunk\r\ndata: {"chunk":"Hello 🌳"}\r\n\r\nevent: replace\ndata: {"target":"reply",\ndata: "text":"Corrected"}\n\nevent: done\ndata: {"reply":"Corrected"}\n\n';
t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({start(controller) {
for (const byte of new TextEncoder().encode(wire)) controller.enqueue(Uint8Array.of(byte));
controller.close();
}})));
await stream("/stream", {}, {onChunk: data => received.push(data.chunk), onReplace: data => received.push(data.text), onDone: data => received.push(data.reply)});
assert.deepEqual(received, ["Hello 🌳", "Corrected", "Corrected"]);
let cancelled = 0;
t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({cancel() { cancelled++; }}), {headers: {"X-Request-ID": "stream-123"}}));
await assert.rejects(stream("/stream", {}, {}, {timeoutMs: 10}), error => error.code === "TIMEOUT" && error.requestId === "stream-123");
assert.equal(cancelled, 1);
const controller = new AbortController();
const pending = stream("/stream", {}, {}, controller.signal);
controller.abort();
await assert.rejects(pending, error => error.code === "ABORTED");
});
+126 -1
View File
@@ -7,6 +7,9 @@ import fs from "fs";
import os from "os";
import path from "path";
import session from "express-session";
import bcrypt from "bcrypt";
import Database from "better-sqlite3";
import {initializeSchema, runMigrations} from "../src/db/schema.js";
import {createApp, createCloseHandler} from "../src/app.js";
class CookieJar {
@@ -271,7 +274,7 @@ test("username and password changes keep account data and sessions consistent",
.run(attachmentId, "accountowner", "picture.png", "rename-test-image", "image/png", 1, 1, 1);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "wrong"}, jar})).status, 403);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 400);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 409);
const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar});
assert.equal(renamed.status, 200);
assert.equal(renamed.json.username, "newowner");
@@ -312,6 +315,7 @@ test("admin diagnostics require admin and return model status", async () => {
assert.equal(userDiagnostics.json.error, "Admin only");
await request(server.baseUrl, "/register", {method: "POST", body: {username: "admin", password: "password123"}});
server.app.locals.db.prepare("UPDATE users SET role = 'admin' WHERE username = ?").run("admin");
await request(server.baseUrl, "/login", {method: "POST", body: {username: "admin", password: "password123"}, jar: adminJar});
const diagnostics = await request(server.baseUrl, "/admin/diagnostics", {jar: adminJar});
assert.equal(diagnostics.status, 200);
@@ -1193,3 +1197,124 @@ test("every served page references existing local assets", async () => {
}
} finally { await server.close(); }
});
test("roles cannot be obtained by registration or rename and revocation affects existing sessions", async t => {
for (const entry of ["register", "rename"]) {
const server = await startTestServer();
t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar,
body: {username: entry === "register" ? "admin" : "ordinary", password: "password123", role: "admin", isAdmin: true}});
if (entry === "rename") {
assert.equal((await request(server.baseUrl, "/admin/reports", {jar})).status, 403);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", jar,
body: {username: "admin", password: "password123", role: "admin"}})).status, 200);
}
const assertPermissions = async status => {
for (const [url, method] of [["/admin/reports", "GET"], ["/admin/diagnostics", "GET"],
["/admin/reports/999", "POST"], ["/admin/personas/market/999/moderate", "POST"]]) {
assert.equal((await request(server.baseUrl, url, {method, jar, body: method === "POST" ? {} : undefined})).status, status, url);
}
};
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.role, "user");
await assertPermissions(403);
server.app.locals.db.prepare("UPDATE users SET role = 'admin' WHERE username = 'admin'").run();
await assertPermissions(200);
await request(server.baseUrl, "/settings/username", {method: "PUT", jar, body: {username: "moderator", password: "password123"}});
await assertPermissions(200);
server.app.locals.db.prepare("UPDATE users SET role = 'user' WHERE username = 'moderator'").run();
await assertPermissions(403);
}
});
test("legacy users named admin migrate to an unprivileged role, idempotently", () => {
const db = new Database(":memory:");
try {
db.exec("CREATE TABLE users (username TEXT PRIMARY KEY, password TEXT NOT NULL); INSERT INTO users VALUES ('admin', 'hash')");
initializeSchema(db);
runMigrations(db, {dropLegacyChats: true});
runMigrations(db, {dropLegacyChats: true});
assert.equal(db.prepare("SELECT role FROM users WHERE username = 'admin'").get().role, "user");
assert.throws(() => db.prepare("UPDATE users SET role = 'owner'").run(), /CHECK/);
} finally { db.close(); }
});
test("simultaneous registrations race at insertion and return one 200 and one 409", async t => {
const server = await startTestServer();
t.after(() => server.close());
const hash = bcrypt.hash;
let hashing = 0, release;
const barrier = new Promise(resolve => { release = resolve; });
t.mock.method(bcrypt, "hash", async (...args) => {
if (++hashing === 2) release();
await barrier;
return hash(...args);
});
const results = await Promise.all([new CookieJar(), new CookieJar()].map(jar => request(server.baseUrl, "/register", {
method: "POST", jar, body: {username: "registration-race", password: "password123"}
})));
assert.deepEqual(results.map(result => result.status).sort(), [200, 409]);
assert.equal(results.find(result => result.status === 409).json.code, "USERNAME_TAKEN");
assert.equal(server.app.locals.db.prepare("SELECT COUNT(*) AS count FROM users").get().count, 1);
assert.equal((await request(server.baseUrl, "/register", {method: "POST", body: {username: "registration-race", password: "password123"}})).status, 409);
});
test("unexpected async auth and health failures reach Express error handling", async t => {
const server = await startTestServer();
t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "health-check", password: "password123"}});
const failure = Object.assign(new Error("private database details"), {code: "SQLITE_ERROR"});
t.mock.method(server.app.locals.repositories, "insertUser", () => { throw failure; });
const result = await request(server.baseUrl, "/register", {method: "POST", body: {username: "broken-insert", password: "password123"}});
assert.equal(result.status, 500);
assert.equal(result.json.error, "Internal server error");
t.mock.method(server.app.locals.modelService, "checkHealth", async () => { throw failure; });
assert.equal((await request(server.baseUrl, "/health", {jar})).status, 500);
assert.equal(server.app.locals.observability.getSnapshot().errorsTotal, 2);
assert.equal((await request(server.baseUrl, "/session")).status, 200);
});
test("browser sanitizer is served from the npm dependency without exposing node_modules", async t => {
const server = await startTestServer();
t.after(() => server.close());
const response = await fetch(server.baseUrl + "/vendor/dompurify.js");
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-cache");
assert.equal(await response.text(), fs.readFileSync(new URL("../node_modules/dompurify/dist/purify.min.js", import.meta.url), "utf8"));
assert.ok((await fetch(server.baseUrl + "/node_modules/dompurify/package.json")).status >= 400);
});
test("persona corrections explicitly replace provisional SSE output before persisting the final answer", async t => {
let pass = 0;
const modelService = {
async streamReply(_model, _messages, onChunk, options) {
const reply = ++pass === 1 ? 'You: I agree.' : 'The guide waits quietly.';
options.onThinking?.('considering', 'considering');
onChunk(reply, reply);
return reply;
},
mapError: error => ({status: 500, body: {error: error.message}})
};
const server = await startTestServer({modelService});
t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "correction", password: "password123"}});
const {json: {persona}} = await request(server.baseUrl, "/personas", {method: "POST", jar, body: {personaType: "assistant", name: "Guide"}});
const {json: {chat}} = await request(server.baseUrl, `/personas/${persona.id}/chat`, {method: "POST", jar, body: {}});
const response = await fetch(server.baseUrl + "/chat/stream", {method: "POST", headers: jar.headers({"Content-Type": "application/json"}),
body: JSON.stringify({chatId: chat.id, message: "Hello", model: "qwen3:14b", reasoning: true})});
const events = (await response.text()).trim().split("\n\n").map(event => ({
type: event.split("\n")[0].slice(7), data: JSON.parse(event.split("\n")[1].slice(6))
}));
assert.equal(pass, 2);
const textEvents = events.filter(event => ["chunk", "replace"].includes(event.type));
assert.deepEqual(textEvents, [
{type: "chunk", data: {chunk: "You: I agree."}},
{type: "replace", data: {target: "thinking", text: ""}},
{type: "replace", data: {target: "reply", text: ""}},
{type: "chunk", data: {chunk: "The guide waits quietly."}}
]);
assert.equal(events.at(-1).type, "done");
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages`, {jar})).json.messages.at(-1).content, "The guide waits quietly.");
});
+86
View File
@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createComposerController} from "../public/js/composerController.js";
import {createConversationState} from "../public/js/conversationState.js";
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
const noop = () => {};
const element = () => ({value: "Hello", style: {}, scrollHeight: 0, scrollTop: 0, clientHeight: 0,
classList: {toggle: noop, add: noop}, setAttribute: noop, focus: noop, querySelectorAll: () => []});
function fixture(overrides = {}) {
const state = createConversationState();
const elements = {msgInput: element(), sendBtn: element(), messagesDiv: element(), modelSelect: {value: "original-model"}};
const calls = {create: 0, streams: [], refresh: [], notices: []};
const controller = createComposerController({state, elements,
attachmentComposer: {pending: false, attachments: [], setBusy: noop, clear: noop, clearSentDraft: noop},
webSearchButton: element(), reasoningToggle: {enabled: true, setBusy: noop, reset: noop}, webSearchToggle: {enabled: true, reset: noop},
setChatActivity: noop, updateComposerPlaceholder: noop, setNotice: message => calls.notices.push(message),
createNewChat: async () => {
calls.create++;
const chat = overrides.creation ? await overrides.creation : {id: 1, title: "New chat"};
if (chat?.id) { state.activeChatId = chat.id; state.chatSessions.push(chat); }
return chat;
},
getChatById: id => state.chatSessions.find(chat => chat.id === id),
updateWorkspaceCopy: noop, renderChatList: noop, updateChatActionState: noop, scrollMessagesToBottom: noop,
addMessage: () => ({isConnected: true, querySelector: () => element(), remove: noop, replaceWith: noop}),
setMessageContent: noop, setActiveChat: async id => calls.refresh.push(id), loadSummary: noop, markOnboardingCompleted: noop, maybeShowPersonaNudge: noop,
api: {put: async () => overrides.rename ? await overrides.rename : {}, post: async () => assert.fail("Send cancellation must abort its own request"),
stream: async (_url, payload, handlers) => {
calls.streams.push(payload);
if (overrides.failure) throw new Error(overrides.failure);
handlers.onDone({reply: "Done"});
}}
});
return {state, elements, calls, ...controller};
}
test("rapid sends lock before chat creation and keep the captured chat/model after navigation during rename", async () => {
const creation = deferred(), rename = deferred();
const s = fixture({creation: creation.promise, rename: rename.promise});
const first = s.sendMessage();
await s.sendMessage();
assert.equal(s.state.isProcessing, true);
assert.equal(s.calls.create, 1);
creation.resolve({id: 7, title: "New chat"});
await new Promise(resolve => setImmediate(resolve));
await s.sendMessage();
s.state.activeChatId = 8;
s.elements.modelSelect.value = "changed-model";
s.elements.msgInput.value = "Draft in another conversation";
rename.resolve({});
await first;
assert.equal(s.calls.streams.length, 1);
assert.equal(s.calls.streams[0].chatId, 7);
assert.equal(s.calls.streams[0].model, "original-model");
assert.equal(s.calls.streams[0].reasoning, true);
assert.deepEqual(s.calls.refresh, []);
assert.deepEqual(s.state.currentMessages, []);
assert.equal(s.elements.msgInput.value, "Draft in another conversation");
assert.equal(s.state.isProcessing, false);
});
test("creation, rename and stream failures unlock sending and retain the draft", async () => {
for (const options of [{creation: Promise.resolve({error: "Creation failed"})}, {rename: Promise.resolve({error: "Rename failed"})}, {failure: "Stream failed"}]) {
const s = fixture(options);
await s.sendMessage();
assert.equal(s.state.isProcessing, false);
assert.equal(s.elements.msgInput.disabled, false);
assert.equal(s.elements.msgInput.value, "Hello");
assert.match(s.calls.notices.at(-1), /failed/);
if (options.failure) assert.equal(s.state.currentMessages[0].deliveryStatus, "failed");
else assert.equal(s.calls.streams.length, 0);
}
});
test("Stop during chat creation prevents a later stream and releases the send lock", async () => {
const creation = deferred();
const s = fixture({creation: creation.promise});
const pending = s.sendMessage();
await s.stopGeneration();
creation.resolve({id: 4, title: "New chat"});
await pending;
assert.equal(s.calls.streams.length, 0);
assert.equal(s.state.isProcessing, false);
assert.equal(s.elements.msgInput.value, "Hello");
});
+4 -3
View File
@@ -76,11 +76,12 @@ test("thinking streams separately and survives reload, retry, branch and import"
globalThis.fetch = async () => new Response(wire);
try {
await stream("/fixture", {}, {
onThinking: data => events.push(["thinking", data.fullThinking]),
onChunk: data => events.push(["answer", data.fullReply])
onThinking: data => events.push(["thinking", data.chunk]),
onChunk: data => events.push(["answer", data.chunk])
});
} finally { globalThis.fetch = originalFetch; }
assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "Consider carefully."], ["answer", "Answer"]]);
assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "carefully."], ["answer", "Answer"]]);
assert.doesNotMatch(wire, /fullReply|fullThinking/);
const bot = (await api(`/chats/${chatId}/messages`)).messages.at(-1);
assert.equal(bot.thinking, "Consider carefully.");
assert.equal(bot.content, "Answer");
+10 -3
View File
@@ -35,6 +35,12 @@ test("inline renderer streams in place, follows only nearby output, commits or r
const oldDocument = globalThis.document, oldFetch = globalThis.fetch;
t.after(() => { globalThis.document = oldDocument; globalThis.fetch = oldFetch; });
globalThis.document = {createElement: tag => new Element(tag)};
const frames = new Map();
let frameId = 0;
const oldRequestFrame = globalThis.requestAnimationFrame, oldCancelFrame = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = callback => { frames.set(++frameId, callback); return frameId; };
globalThis.cancelAnimationFrame = id => frames.delete(id);
t.after(() => { globalThis.requestAnimationFrame = oldRequestFrame; globalThis.cancelAnimationFrame = oldCancelFrame; });
for (const outcome of ["success", "error", "disconnect"]) {
const scroller = new Element(), card = new Element(), later = new Element();
const header = new Element(), oldBody = new Element(), actions = new Element(), oldSources = new Element();
@@ -52,6 +58,7 @@ test("inline renderer streams in place, follows only nearby output, commits or r
const event = async (name, data) => {
controller.enqueue(new TextEncoder().encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`));
await new Promise(resolve => setTimeout(resolve, 0));
for (const callback of [...frames.values()]) callback();
};
assert.equal(card.attributes["aria-busy"], "true");
assert.equal(card.querySelector(".msg-sources"), null);
@@ -60,15 +67,15 @@ test("inline renderer streams in place, follows only nearby output, commits or r
assert.equal(card.querySelector(".loading-shell").textContent, "Searching the web...");
await event("status", {status: "generating", sources: [{title: "New source", url: "https://example.org/new"}]});
assert.notEqual(card.querySelector(".msg-sources"), oldSources);
await event("thinking", {fullThinking: "New reasoning"});
await event("thinking", {chunk: "New reasoning"});
assert.equal(card.querySelector(".msg-reasoning-content").textContent, "New reasoning");
await event("chunk", {fullReply: "Partial"});
await event("chunk", {chunk: "Partial"});
assert.equal(card.querySelector(".msg-content").textContent, "Partial");
assert.equal(scroller.scrollTop, 20);
assert.equal(completed, undefined);
assert.deepEqual(scroller.childNodes, [card, later]);
card.bottom = 1000; // User has scrolled away from this response.
await event("chunk", {fullReply: "Partial answer"});
await event("chunk", {chunk: " answer"});
assert.equal(scroller.scrollTop, 20);
if (outcome === "success") {
await event("done", {reply: "Partial answer", message: {id: 2, content: "Partial answer"}});
+38
View File
@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createStreamRenderer} from "../public/js/streamRenderer.js";
import {createTextStreamEvents} from "../src/services/streamEvents.js";
test("streaming sends linear-sized deltas, applies replacements and batches rendering", () => {
const events = [], frames = new Map(), rendered = [];
let nextId = 0;
const renderer = createStreamRenderer({
onReply: text => rendered.push(["reply", text]), onThinking: text => rendered.push(["thinking", text]),
schedule: callback => { frames.set(++nextId, callback); return nextId; }, cancel: id => frames.delete(id)
});
const emit = createTextStreamEvents((type, data) => {
events.push({type, data});
renderer[{chunk: "onChunk", thinking: "onThinking", replace: "onReplace"}[type]](data);
});
for (let i = 0; i < 1000; i++) emit.onChunk("a", "a".repeat(i + 1));
assert.equal(JSON.stringify(events).length < 45000, true);
assert.equal(frames.size, 1);
assert.equal(rendered.length, 0);
renderer.flush();
assert.deepEqual(rendered, [["reply", "a".repeat(1000)]]);
emit.onThinking("provisional", "provisional");
emit.onThinking("", ""); emit.onChunk("", "");
emit.onChunk("Corrected", "Corrected");
renderer.flush();
assert.deepEqual(rendered.slice(1), [["thinking", ""], ["reply", "Corrected"]]);
assert.ok(events.some(event => event.type === "replace" && event.data.target === "reply"));
renderer.onChunk({chunk: " stale"});
renderer.dispose();
assert.equal(frames.size, 0);
renderer.flush();
assert.equal(rendered.at(-1)[1], "Corrected");
renderer.onChunk({chunk: "", fullReply: ""});
renderer.onChunk({chunk: "Legacy", fullReply: "Legacy"});
renderer.flush();
assert.equal(rendered.at(-1)[1], "Legacy");
});