Dev #14
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user