make some overall QoL improvements

This commit is contained in:
Frederik Spirgi
2026-09-30 11:05:13 +02:00
parent d15f7867d5
commit c69070a6e6
18 changed files with 256 additions and 78 deletions
+9 -2
View File
@@ -67,8 +67,15 @@ Outside production, the default directory is `data/uploads`; `UPLOAD_DIR` overri
it. Startup creates the directory and checks write permission, failing clearly rather it. Startup creates the directory and checks write permission, failing clearly rather
than falling back to another location. Files use `YYYY/MM/DD/<uuid>.<format>` keys. than falling back to another location. Files use `YYYY/MM/DD/<uuid>.<format>` keys.
Do not publish this directory as a static directory. Back up **both the SQLite database Do not publish this directory as a static directory. Back up **both the SQLite database
and uploads directory**. JSON workspace exports contain attachment references only; and uploads directory** for server recovery. The workspace's **Download workspace archive**
imports reconnect existing images owned by the same account, not portable image bytes. button saves a `.tar.gz` containing `workspace.json` and referenced images; import that
archive on another installation to restore chats with attachments. The importer also
accepts older JSON exports, which contain attachment references only and reconnect
images only when those IDs already belong to the importing account. Archives are
limited to 250 MB uncompressed, and each image must meet the destination's upload limit.
`CHAT_LIMIT` defaults to 10 active chats per account. Archived chats remain available
without using an active slot; restoring one requires a free slot.
Authenticated API endpoints: Authenticated API endpoints:
+1 -1
View File
@@ -6,5 +6,5 @@ import globals from "globals";
import { defineConfig } from "eslint/config"; import { defineConfig } from "eslint/config";
export default defineConfig([ export default defineConfig([
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } }, { files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: {...globals.browser, ...globals.node} } },
]); ]);
+1 -1
View File
@@ -9,7 +9,7 @@
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "node --watch server.js", "dev": "node --watch server.js",
"lint": "node scripts/lint.mjs", "lint": "eslint .",
"test": "node --test" "test": "node --test"
}, },
"dependencies": { "dependencies": {
+8 -6
View File
@@ -221,12 +221,12 @@
data-chat-action="personaState">Roleplay state</button> data-chat-action="personaState">Roleplay state</button>
<button id="backupWorkspace" <button id="backupWorkspace"
class="secondary-action chat-drawer-action" class="secondary-action chat-drawer-action"
type="button">Backup workspace</button> type="button">Download workspace archive</button>
<button id="importWorkspace" <button id="importWorkspace"
class="secondary-action chat-drawer-action" class="secondary-action chat-drawer-action"
type="button">Import workspace</button> type="button">Import workspace</button>
<input id="importWorkspaceFile" class="hidden" type="file" <input id="importWorkspaceFile" class="hidden" type="file"
accept="application/json,.json" /> accept="application/json,.json,.tar.gz,application/gzip" />
</div> </div>
</details> </details>
<details class="chat-drawer-section chat-drawer-danger"> <details class="chat-drawer-section chat-drawer-danger">
@@ -316,7 +316,7 @@
</div> </div>
<div id="personaModal" class="modal-overlay side-panel-overlay hidden"> <div id="personaModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card persona-modal-card side-panel-card"> <div class="modal-card persona-modal-card side-panel-card" role="dialog" aria-modal="true" aria-labelledby="personaFormTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p> <p class="eyebrow">Persona editor</p>
@@ -380,7 +380,7 @@
</div> </div>
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden"> <div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card roleplay-starter-card side-panel-card"> <div class="modal-card roleplay-starter-card side-panel-card" role="dialog" aria-modal="true" aria-labelledby="roleplayStarterTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Start roleplay</p> <p class="eyebrow">Start roleplay</p>
@@ -449,7 +449,7 @@
</div> </div>
<div id="onboardingModal" class="modal-overlay hidden"> <div id="onboardingModal" class="modal-overlay hidden">
<div class="modal-card onboarding-card"> <div class="modal-card onboarding-card" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle" aria-describedby="onboardingSubtitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Get started</p> <p class="eyebrow">Get started</p>
@@ -466,7 +466,9 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script> <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="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
<script type="module" src="/js/script.js?v=20260930-chat-usability"></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> <script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
+17 -10
View File
@@ -5,6 +5,7 @@ import { get, post, put } from "./api.js?v=20260909-account-controls";
import { initializeAppearance } from "./themeController.js?v=20260909-account-controls"; import { initializeAppearance } from "./themeController.js?v=20260909-account-controls";
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls"; import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls"; import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
import { openModal, closeModal, topModal } from "./modalFocus.js";
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const marketPersonaList = $("marketPersonaList"); const marketPersonaList = $("marketPersonaList");
@@ -31,7 +32,6 @@ const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
const marketPreviewIntro = $("marketPreviewIntro"); const marketPreviewIntro = $("marketPreviewIntro");
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField"); const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect"); const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
const marketPreviewScenarioField = $("marketPreviewScenarioField");
const marketPreviewScenario = $("marketPreviewScenario"); const marketPreviewScenario = $("marketPreviewScenario");
const marketPreviewStepOne = $("marketPreviewStepOne"); const marketPreviewStepOne = $("marketPreviewStepOne");
const marketPreviewStepTwo = $("marketPreviewStepTwo"); const marketPreviewStepTwo = $("marketPreviewStepTwo");
@@ -74,7 +74,6 @@ const marketPersonaFields = {
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]); const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
let marketPersonasByType = { assistant: [], user: [] }; let marketPersonasByType = { assistant: [], user: [] };
let assistantPersonas = [];
let userPersonas = []; let userPersonas = [];
let pendingMarketPersona = null; let pendingMarketPersona = null;
let editingPersonaId = null; let editingPersonaId = null;
@@ -90,7 +89,7 @@ function setMarketStatus(message, state = "") {
} }
function closeMarketPopup(value = null) { function closeMarketPopup(value = null) {
marketPopupModal.classList.add("hidden"); closeModal(marketPopupModal);
if (marketPopupResolver) { if (marketPopupResolver) {
const resolve = marketPopupResolver; const resolve = marketPopupResolver;
marketPopupResolver = null; marketPopupResolver = null;
@@ -115,7 +114,7 @@ function promptMarketPopup({
marketPopupInput.placeholder = placeholder; marketPopupInput.placeholder = placeholder;
marketPopupConfirm.textContent = confirmLabel; marketPopupConfirm.textContent = confirmLabel;
marketPopupField.classList.remove("hidden"); marketPopupField.classList.remove("hidden");
marketPopupModal.classList.remove("hidden"); openModal(marketPopupModal, marketPopupInput);
marketPopupInput.focus(); marketPopupInput.focus();
marketPopupInput.select(); marketPopupInput.select();
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -241,7 +240,7 @@ function setCollapsiblePreviewText(element, value, maxLength = 220) {
} }
function closeMarketPreview() { function closeMarketPreview() {
marketPreviewModal.classList.add("hidden"); closeModal(marketPreviewModal);
pendingMarketPersona = null; pendingMarketPersona = null;
marketPreviewStep = 1; marketPreviewStep = 1;
marketPreviewUserPersonaSelect.innerHTML = ""; marketPreviewUserPersonaSelect.innerHTML = "";
@@ -318,7 +317,7 @@ function openMarketPreview(persona) {
marketPreviewConfirm.classList.remove("hidden"); marketPreviewConfirm.classList.remove("hidden");
} }
renderMarketPreviewStep(); renderMarketPreviewStep();
marketPreviewModal.classList.remove("hidden"); openModal(marketPreviewModal, isAssistant ? marketPreviewUserPersonaSelect : marketPreviewConfirm);
} }
function openPersonaForm(persona = null) { function openPersonaForm(persona = null) {
@@ -326,12 +325,11 @@ function openPersonaForm(persona = null) {
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character"; marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
populatePersonaForm(marketPersonaFields, persona || {}); populatePersonaForm(marketPersonaFields, persona || {});
setPersonaFormNotice(""); setPersonaFormNotice("");
marketPersonaModal.classList.remove("hidden"); openModal(marketPersonaModal, marketPersonaName);
marketPersonaName.focus();
} }
function closePersonaForm() { function closePersonaForm() {
marketPersonaModal.classList.add("hidden"); closeModal(marketPersonaModal);
editingPersonaId = null; editingPersonaId = null;
setPersonaFormNotice(""); setPersonaFormNotice("");
} }
@@ -510,7 +508,6 @@ async function reportPersona(marketId) {
async function loadOwnedPersonas() { async function loadOwnedPersonas() {
const res = await get("/personas"); const res = await get("/personas");
if (res.error) return false; if (res.error) return false;
assistantPersonas = res.assistantPersonas || [];
userPersonas = res.userPersonas || []; userPersonas = res.userPersonas || [];
activeUserPersonaId = res.activeUserPersonaId || null; activeUserPersonaId = res.activeUserPersonaId || null;
return true; return true;
@@ -608,9 +605,19 @@ marketPopupInput.addEventListener("keydown", (event) => {
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
closeMarketPopup(null); closeMarketPopup(null);
} }
}); });
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
const modal = topModal();
if (!modal) return;
event.preventDefault();
if (modal === marketPopupModal) closeMarketPopup(null);
else if (modal === marketPersonaModal) closePersonaForm();
else if (modal === marketPreviewModal) closeMarketPreview();
});
marketSearchInput.addEventListener("input", applyMarketFilter); marketSearchInput.addEventListener("input", applyMarketFilter);
if (marketAssistantSort) { if (marketAssistantSort) {
marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value)); marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value));
+56 -35
View File
@@ -7,6 +7,7 @@ import { appendSources, createWebSearchToggle } from "./webSearch.js";
import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260930-chat-usability"; import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260930-chat-usability";
import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability"; import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability";
import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls"; import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls";
import { openModal, closeModal, topModal } from "./modalFocus.js";
import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls"; import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls";
import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls"; import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls";
import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls"; import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls";
@@ -243,7 +244,7 @@ function markOnboardingCompleted() {
function closeOnboarding() { function closeOnboarding() {
if (!onboardingModal) return; if (!onboardingModal) return;
onboardingModal.classList.add("hidden"); closeModal(onboardingModal);
} }
function renderOnboardingStep() { function renderOnboardingStep() {
@@ -262,6 +263,7 @@ function renderOnboardingStep() {
button.addEventListener("click", () => { button.addEventListener("click", () => {
onboardingIntent = key; onboardingIntent = key;
renderOnboardingStep(); renderOnboardingStep();
onboardingChoices.querySelector(".active")?.focus();
}); });
onboardingChoices.appendChild(button); onboardingChoices.appendChild(button);
}); });
@@ -299,7 +301,7 @@ function maybeShowOnboarding() {
onboardingStep = 1; onboardingStep = 1;
onboardingIntent = "ask"; onboardingIntent = "ask";
renderOnboardingStep(); renderOnboardingStep();
onboardingModal.classList.remove("hidden"); openModal(onboardingModal, onboardingChoices.querySelector("button"));
} }
function setMessageContent(el, content) { function setMessageContent(el, content) {
const markdownParser = window.marked; const markdownParser = window.marked;
@@ -311,11 +313,6 @@ function setMessageContent(el, content) {
} }
} }
function getChatById(id) { return chatSessions.find((chat) => chat.id === id); } function getChatById(id) { return chatSessions.find((chat) => chat.id === id); }
function summarizeText(value, limit = 96) {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (!normalized) return "";
return normalized.length > limit ? `${normalized.slice(0, limit - 3).trimEnd()}...` : normalized;
}
function getPersonaName(id, items) { function getPersonaName(id, items) {
return items.find((persona) => persona.id === id)?.name || ""; return items.find((persona) => persona.id === id)?.name || "";
} }
@@ -434,10 +431,6 @@ function updateJumpToLatest() {
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true}); messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
jumpToLatest.addEventListener("click", scrollMessagesToBottom); jumpToLatest.addEventListener("click", scrollMessagesToBottom);
function getNewestMessage() {
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null;
}
function getMessageIndexById(messageId) { function getMessageIndexById(messageId) {
return currentMessages.findIndex((message) => message.id === messageId); return currentMessages.findIndex((message) => message.id === messageId);
} }
@@ -505,15 +498,6 @@ function syncRetryStateFromMessage(message) {
return state; return state;
} }
function ensureRetryState(message) {
if (!message?.id) return null;
let state = messageRetryState.get(message.id);
if (!state) {
state = syncRetryStateFromMessage(message);
}
return state;
}
async function openCreatedChat(chat) { async function openCreatedChat(chat) {
if (!chat?.id) throw new Error("The server did not return the newly created chat"); if (!chat?.id) throw new Error("The server did not return the newly created chat");
if (chatSearchInput) { if (chatSearchInput) {
@@ -587,7 +571,7 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
async function deleteChatMessage(messageId, fallbackIndex = -1) { async function deleteChatMessage(messageId, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex); const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error"); if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const { index, message } = target; const { message } = target;
const confirmed = await confirmPopup({ const confirmed = await confirmPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message", eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Delete message", title: "Delete message",
@@ -1246,7 +1230,7 @@ function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix)
function openPersonaForm(persona = null, personaType = "assistant") { function openPersonaForm(persona = null, personaType = "assistant") {
closeChatDrawer(); closeChatDrawer();
closePopup(false); closePopup(false);
personaModal.classList.remove("hidden"); openModal(personaModal, personaNameInput);
if (persona) { if (persona) {
editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant"; editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant";
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`; personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`;
@@ -1266,7 +1250,7 @@ function openPersonaForm(persona = null, personaType = "assistant") {
personaNameInput.focus(); personaNameInput.focus();
} }
function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); } function closePersonaForm() { closeModal(personaModal); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
function populateRoleplayStarter() { function populateRoleplayStarter() {
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return; if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
roleplayCharacterSelect.innerHTML = ""; roleplayCharacterSelect.innerHTML = "";
@@ -1315,12 +1299,11 @@ function openRoleplayStarter(characterId = null) {
populateRoleplayStarter(); populateRoleplayStarter();
if (roleplayScenarioInput) roleplayScenarioInput.value = ""; if (roleplayScenarioInput) roleplayScenarioInput.value = "";
setRoleplayStarterNotice(""); setRoleplayStarterNotice("");
roleplayStarterModal.classList.remove("hidden"); openModal(roleplayStarterModal, roleplayCharacterSelect);
if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus();
} }
function closeRoleplayStarter() { function closeRoleplayStarter() {
if (!roleplayStarterModal) return; if (!roleplayStarterModal) return;
roleplayStarterModal.classList.add("hidden"); closeModal(roleplayStarterModal);
roleplayPresetCharacterId = null; roleplayPresetCharacterId = null;
roleplayStarterStep = 1; roleplayStarterStep = 1;
roleplayStarterConfirm.disabled = false; roleplayStarterConfirm.disabled = false;
@@ -1349,7 +1332,7 @@ function finishPopup(result) {
} }
function closePopup(resolveValue = null) { function closePopup(resolveValue = null) {
if (!popupModal || popupModal.classList.contains("hidden")) return; if (!popupModal || popupModal.classList.contains("hidden")) return;
popupModal.classList.add("hidden"); closeModal(popupModal);
popupField.classList.add("hidden"); popupField.classList.add("hidden");
popupInput.value = ""; popupInput.value = "";
popupConfirmBtn.disabled = false; popupConfirmBtn.disabled = false;
@@ -1377,7 +1360,7 @@ function openPopup(options) {
} else { } else {
popupInput.value = ""; popupInput.value = "";
} }
popupModal.classList.remove("hidden"); openModal(popupModal, options.mode === "prompt" ? popupInput : popupConfirmBtn);
return new Promise((resolve) => { return new Promise((resolve) => {
popupResolver = resolve; popupResolver = resolve;
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -1801,18 +1784,18 @@ function exportCurrentChat() {
} }
async function backupWorkspace() { async function backupWorkspace() {
const response = await fetch("/exports/workspace"); const response = await fetch("/exports/workspace/archive");
const data = await response.json(); if (!response.ok) return setNotice("Workspace archive could not be created.", "error");
const blob = new Blob([JSON.stringify(data.workspace, null, 2)], { type: "application/json;charset=utf-8" }); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.json`; link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setNotice("Workspace backup downloaded.", "success"); setNotice("Workspace archive downloaded with images.", "success");
} }
function formatImportPreview(preview, warnings = []) { function formatImportPreview(preview, warnings = []) {
@@ -1829,6 +1812,31 @@ function formatImportPreview(preview, warnings = []) {
async function importWorkspaceFromFile(file) { async function importWorkspaceFromFile(file) {
if (!file) return; if (!file) return;
setChatLoading(true, "Reading workspace backup..."); setChatLoading(true, "Reading workspace backup...");
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
if (isArchive) {
const submit = async (path, allowDuplicates = false) => {
const body = new FormData();
body.append("archive", file);
if (allowDuplicates) body.append("allowDuplicates", "true");
const response = await fetch(path, {method: "POST", body});
return response.json();
};
try {
const preview = await submit("/imports/workspace/archive/preview");
setChatLoading(false);
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
if (!confirmed) return;
setChatLoading(true, "Importing workspace and images...");
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
if (result.error) return setNotice(result.error, "error");
await Promise.all([loadPersonas(), loadChatSessions()]);
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
} catch { setNotice("Workspace archive could not be imported.", "error"); }
finally { setChatLoading(false); }
return;
}
let workspace; let workspace;
try { try {
workspace = JSON.parse(await file.text()); workspace = JSON.parse(await file.text());
@@ -2020,7 +2028,9 @@ async function checkSession() {
// Starters are offered on workspace home. // Starters are offered on workspace home.
const starter = new URLSearchParams(location.search).get("prompt"); const starter = new URLSearchParams(location.search).get("prompt");
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); } if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
msgInput.focus(); return true; msgInput.focus();
if (!starter && !activeChatId) maybeShowOnboarding();
return true;
} }
async function sendMessage({ retryMessage = null } = {}) { async function sendMessage({ retryMessage = null } = {}) {
@@ -2219,7 +2229,7 @@ if (headerModelButton) headerModelButton.addEventListener("click", (event) => {
openModelSettings(); openModelSettings();
}); });
saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); }); saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); });
roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant")); roleplayNewPersonaBtn.addEventListener("click", () => { closeRoleplayStarter(); openPersonaForm(null, "assistant"); });
clearUserPersonaBtn.addEventListener("click", () => { clearUserPersonaBtn.addEventListener("click", () => {
closeChatDrawer(); closeChatDrawer();
void clearPersona("user"); void clearPersona("user");
@@ -2281,12 +2291,14 @@ if (onboardingContinue) {
onboardingContinue.addEventListener("click", () => { onboardingContinue.addEventListener("click", () => {
onboardingStep = 2; onboardingStep = 2;
renderOnboardingStep(); renderOnboardingStep();
onboardingChoices.querySelector("button")?.focus();
}); });
} }
if (onboardingBack) { if (onboardingBack) {
onboardingBack.addEventListener("click", () => { onboardingBack.addEventListener("click", () => {
onboardingStep = 1; onboardingStep = 1;
renderOnboardingStep(); renderOnboardingStep();
(onboardingChoices.querySelector(".active") || onboardingChoices.querySelector("button"))?.focus();
}); });
} }
if (onboardingSkip) { if (onboardingSkip) {
@@ -2319,6 +2331,15 @@ document.addEventListener("keydown", (event) => {
} }
} }
if (event.key === "Escape") { if (event.key === "Escape") {
const activeModal = topModal();
if (activeModal) {
event.preventDefault();
if (activeModal === popupModal) closePopup(false);
else if (activeModal === personaModal) closePersonaForm();
else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
else if (activeModal === onboardingModal) closeOnboarding();
return;
}
const openMenu = document.querySelector(".msg-action-menu[open]"); const openMenu = document.querySelector(".msg-action-menu[open]");
if (openMenu) { if (openMenu) {
openMenu.open = false; openMenu.open = false;
+14 -5
View File
@@ -9,6 +9,7 @@ import {
} from "./themeController.js?v=20260909-account-controls"; } from "./themeController.js?v=20260909-account-controls";
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls"; import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls"; import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
import { openModal, closeModal, topModal } from "./modalFocus.js";
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const settingsNotice = $("settingsNotice"); const settingsNotice = $("settingsNotice");
@@ -96,7 +97,7 @@ function setPersonaNotice(message = "", state = "") {
} }
function closeSettingsPopup(value = null) { function closeSettingsPopup(value = null) {
settingsPopupModal.classList.add("hidden"); closeModal(settingsPopupModal);
if (settingsPopupResolver) { if (settingsPopupResolver) {
const resolve = settingsPopupResolver; const resolve = settingsPopupResolver;
settingsPopupResolver = null; settingsPopupResolver = null;
@@ -120,7 +121,7 @@ function promptSettingsPopup({
settingsPopupInput.value = value; settingsPopupInput.value = value;
settingsPopupInput.placeholder = placeholder; settingsPopupInput.placeholder = placeholder;
settingsPopupConfirm.textContent = confirmLabel; settingsPopupConfirm.textContent = confirmLabel;
settingsPopupModal.classList.remove("hidden"); openModal(settingsPopupModal, settingsPopupInput);
settingsPopupInput.focus(); settingsPopupInput.focus();
settingsPopupInput.select(); settingsPopupInput.select();
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -232,12 +233,11 @@ function openPersonaModal(persona = null, personaType = "assistant") {
populatePersonaForm(settingsPersonaFields, persona || {}); populatePersonaForm(settingsPersonaFields, persona || {});
syncPersonaExamplesField(); syncPersonaExamplesField();
setPersonaNotice(""); setPersonaNotice("");
settingsPersonaModal.classList.remove("hidden"); openModal(settingsPersonaModal, settingsPersonaName);
settingsPersonaName.focus();
} }
function closePersonaModal() { function closePersonaModal() {
settingsPersonaModal.classList.add("hidden"); closeModal(settingsPersonaModal);
editingPersonaId = null; editingPersonaId = null;
setPersonaNotice(""); setPersonaNotice("");
} }
@@ -571,9 +571,18 @@ settingsPopupInput.addEventListener("keydown", (event) => {
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
closeSettingsPopup(null); closeSettingsPopup(null);
} }
}); });
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
const modal = topModal();
if (!modal) return;
event.preventDefault();
if (modal === settingsPopupModal) closeSettingsPopup(null);
else if (modal === settingsPersonaModal) closePersonaModal();
});
window.addEventListener("load", async () => { window.addEventListener("load", async () => {
initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect }); initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect });
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* @file Verbindet die Abmeldeknöpfe des gemeinsamen Seitenkopfs mit der Sitzungsverwaltung. * @file Verbindet die Abmeldeknöpfe des gemeinsamen Seitenkopfs mit der Sitzungsverwaltung.
*/ */
import {post} from './api.js?v=20260909-account-controls'; import {post} from './api.js?v=20260909-account-controls';
// Every page gets this control from the same server-rendered header. // Every page gets this control from the same server-rendered header.
for (const button of document.querySelectorAll('[data-sign-out]')) { for (const button of document.querySelectorAll('[data-sign-out]')) {
+2 -2
View File
@@ -102,7 +102,7 @@
</main> </main>
<div id="marketPreviewModal" class="modal-overlay hidden"> <div id="marketPreviewModal" class="modal-overlay hidden">
<div class="modal-card market-preview-card"> <div class="modal-card market-preview-card" role="dialog" aria-modal="true" aria-labelledby="marketPreviewTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div> <div>
<p class="eyebrow">Persona preview</p> <p class="eyebrow">Persona preview</p>
@@ -162,7 +162,7 @@
</div> </div>
<div id="marketPersonaModal" class="modal-overlay hidden"> <div id="marketPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card"> <div class="modal-card persona-modal-card" role="dialog" aria-modal="true" aria-labelledby="marketPersonaFormTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">AI Character editor</p> <p class="eyebrow">AI Character editor</p>
+1 -1
View File
@@ -232,7 +232,7 @@
</main> </main>
<div id="settingsPersonaModal" class="modal-overlay hidden"> <div id="settingsPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card"> <div class="modal-card persona-modal-card" role="dialog" aria-modal="true" aria-labelledby="settingsPersonaFormTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p> <p class="eyebrow">Persona editor</p>
+1 -1
View File
@@ -309,7 +309,7 @@ export function createApp(options = {}) {
app.use(createUploadsRouter(attachmentService, config)); app.use(createUploadsRouter(attachmentService, config));
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories})); if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
app.use(createSettingsRouter({repositories})); app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters})); app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
app.use(createPersonasRouter({repositories, chatService, modelService, config})); app.use(createPersonasRouter({repositories, chatService, modelService, config}));
app.use(createSystemRouter({modelService, config})); app.use(createSystemRouter({modelService, config}));
app.use(notFoundHandler); app.use(notFoundHandler);
+4 -3
View File
@@ -87,7 +87,7 @@ export function createRepositories(db, config) {
WHERE cs.username = ? WHERE cs.username = ?
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`), `),
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ?"), countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ? AND archived_at IS NULL"),
getChatSession: db.prepare(` getChatSession: db.prepare(`
SELECT cs.id, SELECT cs.id,
CASE CASE
@@ -887,9 +887,10 @@ export function createRepositories(db, config) {
.run(result.lastInsertRowid, version.content, version.thinking, JSON.stringify(version.sources), version.modelName, version.createdAt); .run(result.lastInsertRowid, version.content, version.thinking, JSON.stringify(version.sources), version.modelName, version.createdAt);
} }
for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) { for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) {
// JSON backups contain references, not files. Only restore existing, owned images. // JSON imports reconnect owned images; archive imports also reconnect newly restored images.
db.prepare("INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,position) SELECT ?,id,? FROM attachments WHERE id=? AND username=? AND status='attached'") db.prepare("INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,position) SELECT ?,id,? FROM attachments WHERE id=? AND username=? AND status IN ('temporary','attached')")
.run(result.lastInsertRowid, position, attachment.id, username); .run(result.lastInsertRowid, position, attachment.id, username);
db.prepare("UPDATE attachments SET status='attached' WHERE id=? AND username=?").run(attachment.id, username);
} }
} }
statements.updateChatGenerationSettings.run( statements.updateChatGenerationSettings.run(
+79 -3
View File
@@ -2,9 +2,11 @@
* @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit. * @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit.
*/ */
import express from "express"; import express from "express";
import multer from "multer";
import {requireLogin} from "../middleware/auth.js"; import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js"; import {validateBody} from "../middleware/validate.js";
import {validateWorkspaceImport} from "../services/workspaceImportService.js"; import {validateWorkspaceImport} from "../services/workspaceImportService.js";
import {createWorkspaceArchive, parseWorkspaceArchive, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js";
const createChatValidator = (body) => ({ const createChatValidator = (body) => ({
value: { value: {
@@ -105,9 +107,29 @@ const preferredModelValidator = (body) => {
return {value: {model}}; return {value: {model}};
}; };
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) { export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}) {
const router = express.Router(); const router = express.Router();
router.use(requireLogin); router.use(requireLogin);
const receiveArchive = multer({storage: multer.memoryStorage(), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive");
const readArchive = (req, res, next) => receiveArchive(req, res, error => {
if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: "Invalid or oversized workspace archive"});
try {
if (!req.file) throw new Error("Choose a workspace archive");
req.archive = parseWorkspaceArchive(req.file.buffer);
next();
} catch (failure) { res.status(400).json({error: failure.message}); }
});
const archiveImages = (workspace, files) => {
const images = new Map();
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
if (images.has(attachment.id)) continue;
const matches = [...files.entries()].filter(([name]) => name.startsWith(`images/${attachment.id}.`));
if (matches.length !== 1) throw new Error(`Image ${attachment.id} is missing from archive`);
const [name, buffer] = matches[0];
images.set(attachment.id, {buffer, size: buffer.length, originalname: name.split("/").at(-1)});
}
return images;
};
router.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => { router.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => {
const chatId = Number(req.params.id); const chatId = Number(req.params.id);
const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id; const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id;
@@ -144,14 +166,14 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json({chat, existing: Boolean(existingChat)}); return res.json({chat, existing: Boolean(existingChat)});
} catch (error) { } catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") { if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`}); return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
} }
throw error; throw error;
} }
} }
if (repositories.countChats(user) >= config.chatLimit) { if (repositories.countChats(user) >= config.chatLimit) {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`}); return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
} }
return res.json({chat: repositories.createChat(user, title), existing: false}); return res.json({chat: repositories.createChat(user, title), existing: false});
@@ -171,6 +193,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf
const chatId = Number(req.params.id); const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user); const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"}); if (!chat) return res.status(404).json({error: "Chat not found"});
if (chat.archived_at && !req.validatedBody.archived && repositories.countChats(req.session.user) >= config.chatLimit) {
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive another chat before restoring this one.`});
}
repositories.updateChatOrganization(chatId, req.session.user, { repositories.updateChatOrganization(chatId, req.session.user, {
folderName: req.validatedBody.folderName, folderName: req.validatedBody.folderName,
isPinned: req.validatedBody.isPinned, isPinned: req.validatedBody.isPinned,
@@ -602,6 +627,57 @@ 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", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
router.get("/exports/workspace/archive", async (req, res) => {
try {
const workspace = repositories.exportWorkspace(req.session.user);
const images = new Map();
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
if (images.has(attachment.id)) continue;
const row = attachmentService.owned(attachment.id, req.session.user);
images.set(attachment.id, [`images/${row.id}.${row.mime_type.split("/")[1]}`, await attachmentService.storage.read(row.storage_key)]);
}
const archive = createWorkspaceArchive(workspace, [...images.values()]);
res.set({"Content-Type": "application/gzip", "Content-Disposition": 'attachment; filename="workspace-backup.tar.gz"', "Cache-Control": "private, no-store"});
return res.send(archive);
} 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) {
try { archiveImages(validation.workspace, req.archive.images); }
catch (error) { validation.ok = false; validation.errors.push(error.message); }
}
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) => {
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") {
return res.status(409).json({error: "Workspace import contains duplicate records", warnings: validation.warnings, preview: validation.preview});
}
let images;
try { images = archiveImages(validation.workspace, req.archive.images); }
catch (error) { return res.status(400).json({error: error.message}); }
const restored = [];
try {
const ids = new Map();
for (const [oldId, file] of images) {
const uploaded = await attachmentService.upload(file, req.session.user, {forImport: true});
restored.push(uploaded.id);
ids.set(oldId, uploaded.id);
}
for (const chat of validation.workspace.chats) for (const message of chat.messages) {
message.attachments = message.attachments.map(attachment => ({id: ids.get(attachment.id)}));
}
const imported = repositories.importWorkspace(req.session.user, validation.workspace);
return res.json({imported, preview: validation.preview});
} catch (error) {
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) => { router.post("/imports/workspace/preview", (req, res) => {
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user)); const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
return res.status(validation.ok ? 200 : 400).json({ return res.status(validation.ok ? 200 : 400).json({
+3 -3
View File
@@ -121,7 +121,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
return res.json({chat: chatService.getOrCreatePersonaChat(user, persona, userPersonaId, null, null)}); return res.json({chat: chatService.getOrCreatePersonaChat(user, persona, userPersonaId, null, null)});
} catch (error) { } catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") { if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`}); return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
} }
throw error; throw error;
} }
@@ -245,7 +245,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary); chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
} catch (error) { } catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") { if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`}); return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
} }
throw error; throw error;
} }
@@ -300,7 +300,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary); chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
} catch (error) { } catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") { if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`}); return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
} }
throw error; throw error;
} }
+3 -3
View File
@@ -162,7 +162,7 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
db.prepare("DELETE FROM attachments WHERE id=?").run(id); db.prepare("DELETE FROM attachments WHERE id=?").run(id);
return cleanup(); return cleanup();
}, },
async upload(file, user) { async upload(file, user, {forImport = false} = {}) {
await cleanup(); await cleanup();
if (!file?.buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image"); if (!file?.buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image");
if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413); if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413);
@@ -178,9 +178,9 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
try { try {
// Bound abandoned uploads per account as well as per submitted message. // Bound abandoned uploads per account as well as per submitted message.
db.transaction(() => { db.transaction(() => {
if (db.prepare("SELECT COUNT(*) AS n FROM attachments WHERE username=? AND status='temporary'").get(user).n >= config.uploadMaxFiles * 4) throw new AttachmentError("Too many pending images. Remove unused uploads first."); if (!forImport && db.prepare("SELECT COUNT(*) AS n FROM attachments WHERE username=? AND status='temporary'").get(user).n >= config.uploadMaxFiles * 4) throw new AttachmentError("Too many pending images. Remove unused uploads first.");
db.prepare("INSERT INTO attachments(id,username,original_name,storage_key,mime_type,size_bytes,width,height) VALUES(?,?,?,?,?,?,?,?)").run(id, user, db.prepare("INSERT INTO attachments(id,username,original_name,storage_key,mime_type,size_bytes,width,height) VALUES(?,?,?,?,?,?,?,?)").run(id, user,
String(file.originalname || "image").replace(/[\\/\x00-\x1f]/g, "_").slice(0, 255), storageKey, `image/${info.format}`, file.size, info.width, info.height); [...String(file.originalname || "image").replace(/[\\/]/g, "_")].map(character => character.charCodeAt(0) < 32 ? "_" : character).join("").slice(0, 255), storageKey, `image/${info.format}`, file.size, info.width, info.height);
})(); })();
} catch (error) { await storage.delete(storageKey); throw error; } } catch (error) { await storage.delete(storageKey); throw error; }
log("attachmentUploadSuccess", {id, size: file.size, mime: `image/${info.format}`}); log("attachmentUploadSuccess", {id, size: file.size, mime: `image/${info.format}`});
+1 -1
View File
@@ -584,7 +584,7 @@ export function createChatService(repositories, modelService, config, webSearchP
const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => { const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => {
if (repositories.countChats(user) >= config.chatLimit) { if (repositories.countChats(user) >= config.chatLimit) {
return { error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`, status: 400 }; return { error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`, status: 400 };
} }
const sourceMessages = messageId == null const sourceMessages = messageId == null
+24
View File
@@ -887,6 +887,30 @@ test("chat search, organization, and workspace export work", async () => {
await server.close(); await server.close();
}); });
test("archiving frees an active chat slot and restoring respects the limit", async () => {
const server = await startTestServer({config: {chatLimit: 1}});
try {
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "archiver", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "archiver", password: "password123"}, jar});
const first = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "First"}, jar});
assert.equal(first.status, 200);
assert.equal((await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Blocked"}, jar})).status, 400);
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
method: "PUT", body: {archived: true}, jar
})).status, 200);
const second = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Second"}, jar});
assert.equal(second.status, 200);
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
method: "PUT", body: {archived: false}, jar
})).status, 400);
await request(server.baseUrl, `/chats/${second.json.chat.id}/organization`, {method: "PUT", body: {archived: true}, jar});
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
method: "PUT", body: {archived: false}, jar
})).status, 200);
} finally { await server.close(); }
});
test("persona clone, version restore, and market feedback endpoints work", async () => { test("persona clone, version restore, and market feedback endpoints work", async () => {
const server = await startTestServer(); const server = await startTestServer();
const jar = new CookieJar(); const jar = new CookieJar();
+31
View File
@@ -132,6 +132,37 @@ test("account rename retains ownership and account deletion removes pending file
await assert.rejects(fs.access(path.join(s.directory, key))); await assert.rejects(fs.access(path.join(s.directory, key)));
}); });
test("workspace archive restores image bytes for another account", async t => {
const s = await setup(t);
const bytes = await s.image("png");
const image = await (await s.upload(bytes)).json();
const chatId = await s.chat();
assert.equal((await s.api("/chat", {chatId, message: "What is in this image?", attachments: [image.id]})).status, 200);
const exported = await s.api("/exports/workspace/archive");
assert.equal(exported.status, 200);
assert.match(exported.headers.get("content-disposition"), /workspace-backup\.tar\.gz/);
const archive = await exported.blob();
const destination = await s.register("destination");
const form = () => {
const data = new FormData();
data.append("archive", archive, "workspace.tar.gz");
return data;
};
const base = new URL("/", exported.url).origin;
const preview = await fetch(base + "/imports/workspace/archive/preview", {method: "POST", headers: {Cookie: destination}, body: form()});
assert.equal(preview.status, 200);
assert.equal((await preview.json()).preview.chats, 1);
const imported = await fetch(base + "/imports/workspace/archive", {method: "POST", headers: {Cookie: destination}, body: form()});
assert.equal(imported.status, 200);
const workspace = await (await s.api("/exports/workspace", null, "GET", destination)).json();
const restored = workspace.workspace.chats[0].messages[0].attachments[0];
assert.notEqual(restored.id, image.id);
const download = await s.api(`/attachments/${restored.id}`, null, "GET", destination);
assert.equal(download.status, 200);
assert.deepEqual(Buffer.from(await download.arrayBuffer()), bytes);
assert.equal((await s.api(`/attachments/${restored.id}`)).status, 404);
});
test("images feed dedicated vision, cache, streamed reasoning, reload and shared branch lifecycle", async t => { test("images feed dedicated vision, cache, streamed reasoning, reload and shared branch lifecycle", async t => {
const s = await setup(t); const s = await setup(t);
const uploads = []; const uploads = [];