Dev #14

Merged
Fretux merged 8 commits from dev into main 2026-09-30 19:56:46 +02:00
9 changed files with 239 additions and 45 deletions
Showing only changes of commit d15f7867d5 - Show all commits
+8 -2
View File
@@ -11,7 +11,7 @@
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
rel="stylesheet">
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
<link rel="stylesheet" href="/css/style.css?v=20260920-web-search-composer" />
<link rel="stylesheet" href="/css/style.css?v=20260930-chat-usability" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
@@ -129,6 +129,8 @@
<p id="modelHelpSummary" class="model-help-summary">Pick a
model to see what it is best suited for.</p>
</div>
<details class="generation-advanced">
<summary>Advanced generation settings</summary>
<div class="generation-settings-grid">
<label class="field"><span>Temperature</span><input
id="chatTemperature" type="number" min="0" max="2"
@@ -146,6 +148,7 @@
<button id="saveGenerationSettings" type="button">Save
generation settings</button>
</div>
</details>
</div>
</details>
<details
@@ -261,8 +264,11 @@
</div>
</div>
<div class="messages-wrap">
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
aria-relevant="additions text"></div>
<button id="jumpToLatest" class="jump-to-latest secondary-action hidden" type="button">Jump to latest</button>
</div>
<div class="input-shell input-shell-focus">
<div class="input-meta">
@@ -462,7 +468,7 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.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=20260923-vision-provenance"></script>
<script type="module" src="/js/script.js?v=20260930-chat-usability"></script>
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
</body>
+14 -2
View File
@@ -363,7 +363,11 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
}
.chat-item:hover { background: var(--surface-hover); }
.chat-item.active { background: var(--brand-soft); border-color: color-mix(in srgb, var(--brand) 35%, var(--border)); }
.chat-item-copy { min-width: 0; display: grid; gap: 2px; }
.chat-item-copy { min-width: 0; width: 100%; padding: 0; display: grid; gap: 2px; justify-items: start; color: inherit; background: transparent; border: 0; text-align: left; font: inherit; }
.chat-item-copy:hover:not(:disabled) { background: transparent; }
.chat-item-copy > span, .chat-item-excerpt { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-item-excerpt { color: var(--text-soft); font-size: .73rem; }
.chat-item:focus-within { border-color: var(--brand); }
.chat-item-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-item-meta { color: var(--text-muted); font-size: .75rem; }
.chat-item-delete { min-height: 30px; padding: 5px 8px; color: var(--text-muted); background: transparent; border: 0; font-size: .72rem; }
@@ -394,11 +398,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.chat-header-actions { display: flex; align-items: center; }
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
.messages-wrap { min-height: 0; position: relative; }
.messages-panel {
height: 100%;
min-height: 0;
padding: 28px max(16px, calc((100% - 940px) / 2));
display: flex; flex-direction: column; gap: 38px; overflow-y: auto;
}
.jump-to-latest { position: absolute; left: 50%; bottom: 14px; z-index: 3; transform: translateX(-50%); box-shadow: var(--shadow-md); white-space: nowrap; }
.messages-empty {
width: min(680px, 100%); margin: auto; padding: clamp(24px, 5vw, 44px);
text-align: center; background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius-lg);
@@ -475,7 +482,10 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.message-images img { max-width: min(360px, 100%); max-height: 300px; object-fit: contain; border-radius: 12px; }
.message-images a { max-width: 100%; }
.image-drag-target { outline: 2px solid currentColor; }
@media (max-width: 480px) { .input-container { grid-template-columns: 36px 36px 36px minmax(0, 1fr) 36px; gap: 3px; } }
@media (max-width: 480px) {
.input-container { grid-template-columns: repeat(3, 36px) minmax(0, 1fr) 44px; grid-template-areas: "message message message message send" "attach search reasoning . ."; gap: 4px; }
#attachImage, #webSearch, #reasoning { width: 36px; height: 36px; min-height: 36px; border-radius: 12px; }
}
#webSearch, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
#webSearch { grid-area: search; }
#reasoning { grid-area: reasoning; }
@@ -491,6 +501,8 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
#send.loading::after { content: "■"; font-size: .85rem; }
.generation-settings-grid { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.generation-settings-grid .field-wide, .generation-settings-grid button { grid-column: 1 / -1; }
.generation-advanced { margin-top: 6px; border-top: 1px solid var(--border); }
.generation-advanced summary { padding: 10px 0; color: var(--text-soft); cursor: pointer; font-size: .8rem; }
/* ---------- Chat settings drawer ---------- */
.chat-drawer {
+22 -2
View File
@@ -27,6 +27,7 @@ export function createAttachmentComposer({onChange, onError}) {
const preview = document.getElementById("uploadPreviews");
const composer = document.querySelector(".input-container");
let items = [];
const drafts = new Map();
let busy = false;
let limits = {maxSizeMb: 10, maxFiles: 5};
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
@@ -73,13 +74,13 @@ export function createAttachmentComposer({onChange, onError}) {
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Upload failed");
item.metadata = result;
if (!items.includes(item)) void fetch(`/attachments/${result.id}`, {method: "DELETE"});
if (![items, ...drafts.values()].some(draft => draft.includes(item))) void fetch(`/attachments/${result.id}`, {method: "DELETE"});
} catch (error) {
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
}
item.uploading = false;
item.controller = null;
render();
if (items.includes(item)) render();
};
const add = files => {
if (busy) return;
@@ -117,6 +118,25 @@ export function createAttachmentComposer({onChange, onError}) {
}
items = [];
render();
},
saveDraft(chatId) {
if (chatId != null) drafts.set(chatId, items);
items = [];
render();
},
restoreDraft(chatId) {
items = drafts.get(chatId) || [];
drafts.delete(chatId);
render();
},
discardDraft(chatId) {
const saved = drafts.get(chatId) || [];
drafts.delete(chatId);
for (const item of saved) {
item.controller?.abort();
URL.revokeObjectURL(item.preview);
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
}
}
};
}
+97 -27
View File
@@ -4,7 +4,7 @@
import { del, get, post, put, stream } from "./api.js?v=20260909-account-controls";
import { regenerateInline } from "./regeneration.js";
import { appendSources, createWebSearchToggle } from "./webSearch.js";
import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260923-vision-provenance";
import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260930-chat-usability";
import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability";
import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls";
import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls";
@@ -131,6 +131,12 @@ let chatSearchResults = null;
let chatSearchError = "";
let chatSearchTimer = null;
let chatSearchRequest = 0;
let chatOpenRequest = 0;
const chatDrafts = new Map();
const jumpToLatest = document.getElementById("jumpToLatest");
let scrollFrame = null;
let renderedChatId = null;
let renderedMessages = new Map();
function setNotice(message = "", state = "") {
@@ -412,11 +418,22 @@ function resizeComposerInput() {
}
function scrollMessagesToBottom() {
requestAnimationFrame(() => {
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
scrollFrame = requestAnimationFrame(() => {
scrollFrame = null;
messagesDiv.scrollTop = messagesDiv.scrollHeight;
updateJumpToLatest();
});
}
function updateJumpToLatest() {
const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight;
jumpToLatest.classList.toggle("hidden", distance < 180);
}
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
jumpToLatest.addEventListener("click", scrollMessagesToBottom);
function getNewestMessage() {
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null;
}
@@ -863,21 +880,46 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
}
function renderMessages() {
messagesDiv.innerHTML = "";
if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
const sameChat = renderedChatId === activeChatId;
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
const previousScrollTop = messagesDiv.scrollTop;
const chat = getChatById(activeChatId);
if (!currentMessages.length && (!chat || chat.assistant_persona_id)) {
messagesDiv.replaceChildren();
renderMessageEmptyState();
renderedMessages = new Map();
} else {
currentMessages.forEach((msg, index) => addMessage(msg, msg.role === "user", false, {
const fragment = document.createDocumentFragment();
const nextRendered = new Map();
currentMessages.forEach((msg, index) => {
const key = msg.id ? String(msg.id) : null;
const snapshot = JSON.stringify(msg);
const previous = sameChat && key ? renderedMessages.get(key) : null;
const card = previous?.snapshot === snapshot && previous.node.isConnected
? previous.node
: addMessage(msg, msg.role === "user", false, {
messageIndex: index,
isNewest: index === currentMessages.length - 1
}));
isNewest: index === currentMessages.length - 1,
detached: true
});
fragment.append(card);
if (key) nextRendered.set(key, {snapshot, node: card});
});
messagesDiv.replaceChildren(fragment);
renderedMessages = nextRendered;
}
renderedChatId = activeChatId;
renderDrawerSummary();
updateChatActionState();
updateComposerPlaceholder();
if (currentMessages.length) {
if (wasReadingEarlier) {
messagesDiv.scrollTop = previousScrollTop;
updateJumpToLatest();
} else if (currentMessages.length) {
scrollMessagesToBottom();
} else {
updateJumpToLatest();
}
}
@@ -1127,7 +1169,7 @@ function renderChatList() {
return;
}
const folderGroups = filtered
.filter((chat) => chat.folder_name && !chat.archived_at)
.filter((chat) => chat.folder_name && !chat.is_pinned && !chat.archived_at)
.reduce((map, chat) => {
const key = String(chat.folder_name).trim();
const items = map.get(key) || [];
@@ -1152,13 +1194,13 @@ function renderChatList() {
list.setAttribute("role", "list");
section.append(heading, list);
group.items.forEach((chat) => {
const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
const item = document.createElement("div"), content = document.createElement("button"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
item.setAttribute("role", "button");
item.tabIndex = 0;
item.setAttribute("aria-label", `Open chat ${chat.title}`);
if (chat.id === activeChatId) item.setAttribute("aria-current", "page");
content.className = "chat-item-copy";
item.setAttribute("role", "listitem");
content.type = "button";
content.className = "chat-item-copy secondary-action";
content.setAttribute("aria-label", `Open chat ${chat.title}`);
if (chat.id === activeChatId) content.setAttribute("aria-current", "page");
title.textContent = chat.title;
meta.className = "chat-item-meta";
const chatType = chat.assistant_persona_id
@@ -1175,16 +1217,19 @@ function renderChatList() {
await deleteChat(chat.id);
});
content.append(title, meta);
if (query && chat.matched_content) {
const excerpt = document.createElement("small");
const normalized = chat.matched_content.replace(/\s+/g, " ");
const matchAt = normalized.toLowerCase().indexOf(query);
const start = Math.max(0, matchAt - 35);
excerpt.className = "chat-item-excerpt";
excerpt.textContent = `${start ? "…" : ""}${normalized.slice(start, start + 110)}${normalized.length > start + 110 ? "…" : ""}`;
content.append(excerpt);
}
item.append(content, remove);
item.addEventListener("click", () => {
content.addEventListener("click", () => {
closeMobileSidebar();
setActiveChat(chat.id);
});
item.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
closeMobileSidebar();
void setActiveChat(chat.id);
void setActiveChat(chat.id, {matchMessageId: query ? chat.matched_message_id : null});
});
list.appendChild(item);
});
@@ -1500,8 +1545,21 @@ async function stopGeneration() {
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
}
async function setActiveChat(id, {preserveComposer = false} = {}) {
if (!preserveComposer && id !== activeChatId) attachmentComposer.discard();
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
const openRequest = ++chatOpenRequest;
if (!preserveComposer && id !== activeChatId) {
if (activeChatId != null) {
chatDrafts.set(activeChatId, msgInput.value);
attachmentComposer.saveDraft(activeChatId);
msgInput.value = chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id);
resizeComposerInput();
} else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) {
msgInput.value = chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id);
resizeComposerInput();
}
}
if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat...");
setChatActivity(true, {
@@ -1513,16 +1571,27 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
history.replaceState(null, "", `/app/chats/${id}`);
messageRetryState = new Map();
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
const res = await get(`/chats/${id}/messages`);
let res = await get(`/chats/${id}/messages`);
if (openRequest !== chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
if (res.error) {
setChatLoading(false);
setChatActivity(false);
return setNotice(res.error, "error");
}
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
if (!activated.error) res = await get(`/chats/${id}/messages`);
if (openRequest !== chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
}
const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat);
populateGenerationSettings(res.chat || existing);
currentMessages = res.messages || []; renderMessages();
if (matchMessageId) requestAnimationFrame(() => {
const target = [...messagesDiv.querySelectorAll("[data-message-id]")]
.find(node => Number(node.dataset.messageId) === Number(matchMessageId));
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
});
updateSendState();
setChatLoading(false);
setChatActivity(false);
@@ -1585,7 +1654,6 @@ function scheduleChatSearch() {
}
async function createNewChat({preserveComposer = false} = {}) {
if (!preserveComposer) attachmentComposer.discard();
setChatLoading(true, "Creating chat...");
setChatActivity(true, {
eyebrow: "Creating chat",
@@ -1905,7 +1973,9 @@ async function deleteChat(id) {
return setNotice(res.error, "error");
}
chatSessions = chatSessions.filter((item) => item.id !== id);
if (activeChatId === id) { activeChatId = null; currentMessages = []; renderMessages(); }
chatDrafts.delete(id);
attachmentComposer.discardDraft(id);
if (activeChatId === id) { attachmentComposer.discard(); msgInput.value = ""; activeChatId = null; currentMessages = []; renderMessages(); }
messageRetryState = new Map();
await loadSummary(); renderChatList();
if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat();
+2 -4
View File
@@ -1,7 +1,7 @@
/**
* @file Steuert die Einstellungsoberfläche für Darstellung, Konto, Personas und Diagnosen.
*/
import { del, get, post, put } from "./api.js?v=20260909-account-controls";
import { del, get, post, put, request } from "./api.js?v=20260909-account-controls";
import {
applyTheme as applySharedTheme,
applyThemeMode as applySharedThemeMode,
@@ -65,7 +65,6 @@ const settingsPopupConfirm = $("settingsPopupConfirm");
const settingsPopupTitle = $("settingsPopupTitle");
const settingsPopupEyebrow = $("settingsPopupEyebrow");
const settingsPopupDescription = $("settingsPopupDescription");
const settingsPopupField = $("settingsPopupField");
const settingsPopupInputLabel = $("settingsPopupInputLabel");
const settingsPopupInput = $("settingsPopupInput");
const settingsPersonaFields = {
@@ -500,9 +499,8 @@ usernameForm.addEventListener("submit", async (event) => {
setNotice(res.error, "error");
return;
}
currentUsernameInput.value = res.username;
newUsernameInput.value = res.username;
usernamePasswordInput.value = "";
if (!await loadProfile()) return;
setNotice("Username updated.", "success");
});
+1 -1
View File
@@ -317,7 +317,7 @@
</div>
</div>
</div>
<script type="module" src="/js/settings.js?v=20260923-asset-paths"></script>
<script type="module" src="/js/settings.js?v=20260930-account-fixes"></script>
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
</body>
+12 -4
View File
@@ -381,7 +381,7 @@ export function createRepositories(db, config) {
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
searchChats: db.prepare(`
SELECT DISTINCT cs.id,
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
@@ -408,18 +408,23 @@ export function createRepositories(db, config) {
cs.system_instruction,
cs.created_at,
cs.updated_at,
cm.id AS matched_message_id,
cm.content AS matched_content
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
LEFT JOIN chat_messages cm ON cm.id = (
SELECT m.id FROM chat_messages m
WHERE m.chat_id = cs.id AND LOWER(m.content) LIKE ?
ORDER BY m.id DESC LIMIT 1
)
WHERE cs.username = ?
AND (
LOWER(CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END) LIKE ?
OR LOWER(COALESCE(cm.content, '')) LIKE ?
OR cm.id IS NOT NULL
)
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`),
@@ -714,6 +719,9 @@ export function createRepositories(db, config) {
};
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
// Existing username references do not all cascade on update. Check them at commit,
// after every owned row has been moved to the new username.
db.pragma("defer_foreign_keys = ON");
statements.updateUsername.run(nextUsername, currentUsername);
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
statements.updatePersonasUsername.run(nextUsername, currentUsername);
@@ -945,7 +953,7 @@ export function createRepositories(db, config) {
listChats: (username) => statements.listChatSessions.all(username),
searchChats(username, query) {
const like = `%${String(query || "").trim().toLowerCase()}%`;
return statements.searchChats.all(username, like, like);
return statements.searchChats.all(like, username, like);
},
countChats: (username) => statements.countChatSessions.get(username).count,
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
+51
View File
@@ -252,6 +252,48 @@ test("account export, logout-all, and deletion controls work", async () => {
await server.close();
});
test("username and password changes keep account data and sessions consistent", async t => {
const server = await startTestServer();
t.after(() => server.close());
const jar = new CookieJar();
const otherSession = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "accountowner", password: "old-password"}});
await request(server.baseUrl, "/register", {method: "POST", body: {username: "alreadytaken", password: "password123"}});
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar})).status, 200);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar: otherSession})).status, 200);
const chat = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Keep this chat"}, jar});
assert.equal(chat.status, 200);
const persona = await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Keeper", personaType: "user"}, jar});
assert.equal(persona.status, 200);
const db = server.app.locals.db;
const attachmentId = "12345678-1234-1234-1234-123456789abc";
db.prepare("INSERT INTO attachments (id, username, original_name, storage_key, mime_type, size_bytes, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run(attachmentId, "accountowner", "picture.png", "rename-test-image", "image/png", 1, 1, 1);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "wrong"}, jar})).status, 403);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 400);
const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar});
assert.equal(renamed.status, 200);
assert.equal(renamed.json.username, "newowner");
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.username, "newowner");
assert.equal((await request(server.baseUrl, "/session", {jar})).json.user, "newowner");
assert.equal((await request(server.baseUrl, "/session", {jar: otherSession})).json.user, null);
assert.equal((await request(server.baseUrl, "/chats", {jar})).json.chats[0].id, chat.json.chat.id);
assert.equal((await request(server.baseUrl, "/personas", {jar})).json.userPersonas[0].id, persona.json.persona.id);
assert.equal(db.prepare("SELECT username FROM attachments WHERE id = ?").get(attachmentId).username, "newowner");
assert.deepEqual(db.pragma("foreign_key_check"), []);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "old-password"}, jar: otherSession})).status, 200);
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", body: {currentPassword: "wrong", newPassword: "new-password"}, jar})).status, 403);
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", body: {currentPassword: "old-password", newPassword: "new-password"}, jar})).status, 200);
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).status, 200);
assert.equal((await request(server.baseUrl, "/session", {jar: otherSession})).json.user, null);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "old-password"}})).status, 403);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "new-password"}})).status, 200);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "new-password"}})).status, 400);
assert.equal((await request(server.baseUrl, "/settings/export", {jar})).json.account.username, "newowner");
});
test("admin diagnostics require admin and return model status", async () => {
const server = await startTestServer({
config: {
@@ -772,9 +814,18 @@ test("chat search, organization, and workspace export work", async () => {
jar
});
await request(server.baseUrl, "/chat", {
method: "POST",
body: {chatId, message: "another keyword passage", model: "mistral:latest"},
jar
});
const search = await request(server.baseUrl, "/chats/search?q=keyword", {jar});
assert.equal(search.status, 200);
assert.equal(search.json.chats.length, 1);
assert.equal(search.json.chats[0].matched_content, "another keyword passage");
const messages = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
assert.equal(search.json.chats[0].matched_message_id, messages.json.messages.find(message => message.content === "another keyword passage").id);
const organize = await request(server.baseUrl, `/chats/${chatId}/organization`, {
method: "PUT",
+29
View File
@@ -97,3 +97,32 @@ test("discarding a chat draft aborts upload, deletes temporary files, and serial
composer.clear();
assert.deepEqual(deletes, [`/attachments/${temporaryId}`], "Sent attachments are retained");
});
test("attachments stay with their chat while uploads finish after switching", async t => {
const originalDocument = globalThis.document, originalFetch = globalThis.fetch;
t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; });
const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()]));
globalThis.document = {getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element()};
let completeUpload;
const deletes = [];
globalThis.fetch = (url) => {
if (url === "/uploads/config") return Promise.resolve(Response.json({maxFiles: 5, maxSizeMb: 10}));
if (url === "/uploads") return new Promise(resolve => { completeUpload = resolve; });
deletes.push(url);
return Promise.resolve(Response.json({success: true}));
};
const composer = createAttachmentComposer({onChange() {}, onError: message => assert.fail(message)});
elements.imagePicker.files = [new File(["fixture"], "draft.png", {type: "image/png"})];
elements.imagePicker.onchange();
composer.saveDraft(1);
composer.restoreDraft(2);
assert.equal(composer.attachments.length, 0);
const metadata = {id: "12345678-1234-1234-1234-123456789abc"};
completeUpload(Response.json(metadata));
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(deletes, []);
composer.saveDraft(2);
composer.restoreDraft(1);
assert.deepEqual(composer.attachments, [metadata]);
composer.discard();
});