diff --git a/public/chat.html b/public/chat.html index 10d2a50..c83b763 100644 --- a/public/chat.html +++ b/public/chat.html @@ -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"> - + @@ -129,6 +129,8 @@

Pick a model to see what it is best suited for.

+
+ Advanced generation settings
+
-
+
+
+ +
@@ -462,7 +468,7 @@ - + diff --git a/public/css/style.css b/public/css/style.css index 2312130..46d9bf2 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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 { diff --git a/public/js/attachments.js b/public/js/attachments.js index d46d0b9..3fd88e3 100644 --- a/public/js/attachments.js +++ b/public/js/attachments.js @@ -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"}); + } } }; } diff --git a/public/js/script.js b/public/js/script.js index 0ca4395..54d7c8f 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -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, { - messageIndex: index, - isNewest: index === currentMessages.length - 1 - })); + 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, + 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(); diff --git a/public/js/settings.js b/public/js/settings.js index 331ff73..bee207c 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -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"); }); diff --git a/public/settings.html b/public/settings.html index 9bded4f..32d750c 100644 --- a/public/settings.html +++ b/public/settings.html @@ -317,7 +317,7 @@
- + diff --git a/src/db/repositories.js b/src/db/repositories.js index ffd9546..2652424 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -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), diff --git a/test/app.test.js b/test/app.test.js index 8236c87..193999f 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -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", diff --git a/test/attachmentComposer.test.js b/test/attachmentComposer.test.js index 1b19a2d..eadf598 100644 --- a/test/attachmentComposer.test.js +++ b/test/attachmentComposer.test.js @@ -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(); +});