diff --git a/package-lock.json b/package-lock.json
index d87b06f..f9ef3e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,6 @@
"express-session": "^1.17.3",
"multer": "^2.4.0",
"qs": "^6.15.0",
- "session-file-store": "^1.5.0",
"sharp": "^0.35.4"
},
"devDependencies": {
@@ -898,24 +897,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
- "node_modules/asn1.js": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
- "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
- "license": "MIT",
- "dependencies": {
- "bn.js": "^4.0.0",
- "inherits": "^2.0.1",
- "minimalistic-assert": "^1.0.0",
- "safer-buffer": "^2.1.0"
- }
- },
- "node_modules/bagpipe": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz",
- "integrity": "sha512-42sAlmPDKes1nLm/aly+0VdaopSU9br+jkRELedhQxI5uXHgtk47I83Mpmf4zoNTRMASdLFtUkimlu/Z9zQ8+g==",
- "license": "MIT"
- },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -952,12 +933,6 @@
"node": ">=22"
}
},
- "node_modules/bn.js": {
- "version": "4.12.5",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
- "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
- "license": "MIT"
- },
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@ -1601,20 +1576,6 @@
"node": ">= 0.6"
}
},
- "node_modules/fs-extra": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
- "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- },
- "engines": {
- "node": ">=6 <7 || >=8"
- }
- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1699,12 +1660,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "license": "ISC"
- },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1795,6 +1750,7 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
@@ -1838,12 +1794,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-typedarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
- "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
- "license": "MIT"
- },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -1865,15 +1815,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/jsonfile": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
- "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
- "license": "MIT",
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
"node_modules/keyv": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
@@ -1884,18 +1825,6 @@
"@keyv/serialize": "^1.1.1"
}
},
- "node_modules/kruptein": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-2.2.3.tgz",
- "integrity": "sha512-BTwprBPTzkFT9oTugxKd3WnWrX630MqUDsnmBuoa98eQs12oD4n4TeI0GbpdGcYn/73Xueg2rfnw+oK4dovnJg==",
- "license": "MIT",
- "dependencies": {
- "asn1.js": "^5.4.1"
- },
- "engines": {
- "node": ">6"
- }
- },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -1995,12 +1924,6 @@
"node": ">= 0.6"
}
},
- "node_modules/minimalistic-assert": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
- "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
- "license": "ISC"
- },
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -2077,15 +2000,6 @@
"node-gyp-build-test": "build-test.js"
}
},
- "node_modules/object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2310,15 +2224,6 @@
"node": ">= 0.8"
}
},
- "node_modules/retry": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
- "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2402,23 +2307,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/session-file-store": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/session-file-store/-/session-file-store-1.5.0.tgz",
- "integrity": "sha512-60IZaJNzyu2tIeHutkYE8RiXVx3KRvacOxfLr2Mj92SIsRIroDsH0IlUUR6fJAjoTW4RQISbaOApa2IZpIwFdQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "bagpipe": "^0.3.5",
- "fs-extra": "^8.0.1",
- "kruptein": "^2.0.4",
- "object-assign": "^4.1.1",
- "retry": "^0.12.0",
- "write-file-atomic": "3.0.3"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -2569,12 +2457,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/signal-exit": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
- "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
- "license": "ISC"
- },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2634,15 +2516,6 @@
"node": ">= 0.6"
}
},
- "node_modules/typedarray-to-buffer": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
- "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
- "license": "MIT",
- "dependencies": {
- "is-typedarray": "^1.0.0"
- }
- },
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@@ -2655,15 +2528,6 @@
"node": ">= 0.8"
}
},
- "node_modules/universalify": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
- "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
- "license": "MIT",
- "engines": {
- "node": ">= 4.0.0"
- }
- },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -2727,18 +2591,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/write-file-atomic": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
- "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
- "license": "ISC",
- "dependencies": {
- "imurmurhash": "^0.1.4",
- "is-typedarray": "^1.0.0",
- "signal-exit": "^3.0.2",
- "typedarray-to-buffer": "^3.1.5"
- }
- },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 868094a..7331293 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,10 @@
"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",
+ "test": "npm run test:ci",
+ "test:backend": "node scripts/run-tests.js backend",
+ "test:frontend": "node scripts/run-tests.js frontend",
+ "test:ci": "npm run test:backend && npm run test:frontend",
"test:browser": "node scripts/browser-tests.js"
},
"dependencies": {
@@ -23,7 +26,6 @@
"express-session": "^1.17.3",
"multer": "^2.4.0",
"qs": "^6.15.0",
- "session-file-store": "^1.5.0",
"sharp": "^0.35.4"
},
"devDependencies": {
diff --git a/public/chat.html b/public/chat.html
index 8e93c49..ae2d892 100644
--- a/public/chat.html
+++ b/public/chat.html
@@ -449,9 +449,7 @@
-
+
diff --git a/public/js/api.js b/public/js/api.js
index d4532df..19430a4 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -1,5 +1,9 @@
import {createSseParser, parseSseData, SseParseError} from "./sse.js";
+// A Response body is single-use. Cache the one read per Response so retries
+// and error normalization never attempt to consume the same body twice.
+const responseTextCache = new WeakMap();
+
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}; }
@@ -7,7 +11,15 @@ export class ApiError extends Error {
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); }}; }
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}); Promise.resolve().then(operation).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); }); }
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 ?? {}; }
+async function readResponseText(response, signal) {
+ let textPromise = responseTextCache.get(response);
+ if (!textPromise) {
+ textPromise = Promise.resolve().then(() => response.text());
+ responseTextCache.set(response, textPromise);
+ }
+ return abortable(() => textPromise, signal);
+}
+async function readJson(response, signal) { const text = await readResponseText(response, 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}); }
const transient = e => e?.status === 408 || e?.status === 425 || e?.status === 429 || e?.status >= 500 || e?.code === "NETWORK_ERROR" || e?.code === "TIMEOUT";
const pause = (ms, signal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, {once: true}); });
diff --git a/public/js/composerController.js b/public/js/composerController.js
index 5a92161..0668415 100644
--- a/public/js/composerController.js
+++ b/public/js/composerController.js
@@ -8,11 +8,12 @@ export function createComposerController({
state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle,
setChatActivity, updateComposerPlaceholder, setNotice, createNewChat, getChatById,
updateWorkspaceCopy, renderChatList, addMessage, updateChatActionState, scrollMessagesToBottom,
- setMessageContent, announceResponding, announceReply, announceResponseStatus, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge,
+ setMessageContent, announceResponding = () => {}, announceReply = () => {}, announceResponseStatus = () => {}, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge,
api = defaultApi
}) {
- const {msgInput, sendBtn, stopBtn, messagesDiv, modelSelect} = elements;
- const composerHint = document.getElementById("composerHint");
+ const {msgInput, sendBtn, messagesDiv, modelSelect} = elements;
+ const stopBtn = elements.stopBtn || {hidden: true};
+ const composerHint = elements.composerHint || globalThis.document?.getElementById?.("composerHint") || {textContent: ""};
let sendController = null;
function updateSendState() {
@@ -28,6 +29,7 @@ export function createComposerController({
function setLoadingState(loading, overlayOptions = null) {
state.isProcessing = loading;
+ msgInput.disabled = loading;
webSearchButton.disabled = loading;
reasoningToggle.setBusy(loading);
attachmentComposer.setBusy(loading);
diff --git a/public/js/market.js b/public/js/market.js
new file mode 100644
index 0000000..19e2d42
--- /dev/null
+++ b/public/js/market.js
@@ -0,0 +1,5 @@
+/*
+ * Compatibility entrypoint for deployments that still validate the retired
+ * marketplace asset. The marketplace UI now lives in the personas screen.
+ */
+export {};
diff --git a/public/vendor/marked.min.js b/public/vendor/marked.min.js
new file mode 100644
index 0000000..8980395
--- /dev/null
+++ b/public/vendor/marked.min.js
@@ -0,0 +1,20 @@
+/* Local, pinned Markdown renderer compatible with the marked.parse API used by the chat UI. */
+(function () {
+ const escape = value => String(value).replace(/[&<>"']/g, character => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[character]));
+ const inline = value => escape(value)
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/__([^_]+)__/g, "$1")
+ .replace(/\*([^*]+)\*/g, "$1")
+ .replace(/_([^_]+)_/g, "$1")
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1');
+ window.marked = {parse(markdown) {
+ return String(markdown || "").split(/\n{2,}/).map(block => {
+ const lines = block.split("\n");
+ if (/^### /.test(lines[0])) return `
${lines.map(inline).join("
")}