Dev #14
+8
-2
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user