make some overall QoL improvements
This commit is contained in:
+10
-4
@@ -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"
|
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">
|
rel="stylesheet">
|
||||||
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
|
<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">
|
<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/pages.css?v=20260909-account-controls">
|
||||||
<link rel="stylesheet" href="/css/shell.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
|
<p id="modelHelpSummary" class="model-help-summary">Pick a
|
||||||
model to see what it is best suited for.</p>
|
model to see what it is best suited for.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="generation-advanced">
|
||||||
|
<summary>Advanced generation settings</summary>
|
||||||
<div class="generation-settings-grid">
|
<div class="generation-settings-grid">
|
||||||
<label class="field"><span>Temperature</span><input
|
<label class="field"><span>Temperature</span><input
|
||||||
id="chatTemperature" type="number" min="0" max="2"
|
id="chatTemperature" type="number" min="0" max="2"
|
||||||
@@ -146,6 +148,7 @@
|
|||||||
<button id="saveGenerationSettings" type="button">Save
|
<button id="saveGenerationSettings" type="button">Save
|
||||||
generation settings</button>
|
generation settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details
|
<details
|
||||||
@@ -261,8 +264,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
|
<div class="messages-wrap">
|
||||||
aria-relevant="additions text"></div>
|
<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-shell input-shell-focus">
|
||||||
<div class="input-meta">
|
<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/marked/marked.min.js" 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=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>
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</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: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.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-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.chat-item-meta { color: var(--text-muted); font-size: .75rem; }
|
.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; }
|
.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; }
|
.chat-header-actions { display: flex; align-items: center; }
|
||||||
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
|
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
|
||||||
|
|
||||||
|
.messages-wrap { min-height: 0; position: relative; }
|
||||||
.messages-panel {
|
.messages-panel {
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 28px max(16px, calc((100% - 940px) / 2));
|
padding: 28px max(16px, calc((100% - 940px) / 2));
|
||||||
display: flex; flex-direction: column; gap: 38px; overflow-y: auto;
|
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 {
|
.messages-empty {
|
||||||
width: min(680px, 100%); margin: auto; padding: clamp(24px, 5vw, 44px);
|
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);
|
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 img { max-width: min(360px, 100%); max-height: 300px; object-fit: contain; border-radius: 12px; }
|
||||||
.message-images a { max-width: 100%; }
|
.message-images a { max-width: 100%; }
|
||||||
.image-drag-target { outline: 2px solid currentColor; }
|
.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, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
|
||||||
#webSearch { grid-area: search; }
|
#webSearch { grid-area: search; }
|
||||||
#reasoning { grid-area: reasoning; }
|
#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; }
|
#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 { 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-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 settings drawer ---------- */
|
||||||
.chat-drawer {
|
.chat-drawer {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
const preview = document.getElementById("uploadPreviews");
|
const preview = document.getElementById("uploadPreviews");
|
||||||
const composer = document.querySelector(".input-container");
|
const composer = document.querySelector(".input-container");
|
||||||
let items = [];
|
let items = [];
|
||||||
|
const drafts = new Map();
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let limits = {maxSizeMb: 10, maxFiles: 5};
|
let limits = {maxSizeMb: 10, maxFiles: 5};
|
||||||
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
|
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();
|
const result = await response.json();
|
||||||
if (!response.ok) throw new Error(result.error || "Upload failed");
|
if (!response.ok) throw new Error(result.error || "Upload failed");
|
||||||
item.metadata = result;
|
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) {
|
} catch (error) {
|
||||||
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
|
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
|
||||||
}
|
}
|
||||||
item.uploading = false;
|
item.uploading = false;
|
||||||
item.controller = null;
|
item.controller = null;
|
||||||
render();
|
if (items.includes(item)) render();
|
||||||
};
|
};
|
||||||
const add = files => {
|
const add = files => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -117,6 +118,25 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
}
|
}
|
||||||
items = [];
|
items = [];
|
||||||
render();
|
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"});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-28
@@ -4,7 +4,7 @@
|
|||||||
import { del, get, post, put, stream } from "./api.js?v=20260909-account-controls";
|
import { del, get, post, put, stream } from "./api.js?v=20260909-account-controls";
|
||||||
import { regenerateInline } from "./regeneration.js";
|
import { regenerateInline } from "./regeneration.js";
|
||||||
import { appendSources, createWebSearchToggle } from "./webSearch.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 { 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 { onboardingPrompts, requestedChatId } from "./constants.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 chatSearchError = "";
|
||||||
let chatSearchTimer = null;
|
let chatSearchTimer = null;
|
||||||
let chatSearchRequest = 0;
|
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 = "") {
|
function setNotice(message = "", state = "") {
|
||||||
@@ -412,11 +418,22 @@ function resizeComposerInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollMessagesToBottom() {
|
function scrollMessagesToBottom() {
|
||||||
requestAnimationFrame(() => {
|
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||||
|
scrollFrame = requestAnimationFrame(() => {
|
||||||
|
scrollFrame = null;
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
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() {
|
function getNewestMessage() {
|
||||||
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null;
|
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null;
|
||||||
}
|
}
|
||||||
@@ -863,21 +880,46 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMessages() {
|
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);
|
const chat = getChatById(activeChatId);
|
||||||
if (!currentMessages.length && (!chat || chat.assistant_persona_id)) {
|
if (!currentMessages.length && (!chat || chat.assistant_persona_id)) {
|
||||||
|
messagesDiv.replaceChildren();
|
||||||
renderMessageEmptyState();
|
renderMessageEmptyState();
|
||||||
|
renderedMessages = new Map();
|
||||||
} else {
|
} else {
|
||||||
currentMessages.forEach((msg, index) => addMessage(msg, msg.role === "user", false, {
|
const fragment = document.createDocumentFragment();
|
||||||
messageIndex: index,
|
const nextRendered = new Map();
|
||||||
isNewest: index === currentMessages.length - 1
|
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();
|
renderDrawerSummary();
|
||||||
updateChatActionState();
|
updateChatActionState();
|
||||||
updateComposerPlaceholder();
|
updateComposerPlaceholder();
|
||||||
if (currentMessages.length) {
|
if (wasReadingEarlier) {
|
||||||
|
messagesDiv.scrollTop = previousScrollTop;
|
||||||
|
updateJumpToLatest();
|
||||||
|
} else if (currentMessages.length) {
|
||||||
scrollMessagesToBottom();
|
scrollMessagesToBottom();
|
||||||
|
} else {
|
||||||
|
updateJumpToLatest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,7 +1169,7 @@ function renderChatList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const folderGroups = filtered
|
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) => {
|
.reduce((map, chat) => {
|
||||||
const key = String(chat.folder_name).trim();
|
const key = String(chat.folder_name).trim();
|
||||||
const items = map.get(key) || [];
|
const items = map.get(key) || [];
|
||||||
@@ -1152,13 +1194,13 @@ function renderChatList() {
|
|||||||
list.setAttribute("role", "list");
|
list.setAttribute("role", "list");
|
||||||
section.append(heading, list);
|
section.append(heading, list);
|
||||||
group.items.forEach((chat) => {
|
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.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
|
||||||
item.setAttribute("role", "button");
|
item.setAttribute("role", "listitem");
|
||||||
item.tabIndex = 0;
|
content.type = "button";
|
||||||
item.setAttribute("aria-label", `Open chat ${chat.title}`);
|
content.className = "chat-item-copy secondary-action";
|
||||||
if (chat.id === activeChatId) item.setAttribute("aria-current", "page");
|
content.setAttribute("aria-label", `Open chat ${chat.title}`);
|
||||||
content.className = "chat-item-copy";
|
if (chat.id === activeChatId) content.setAttribute("aria-current", "page");
|
||||||
title.textContent = chat.title;
|
title.textContent = chat.title;
|
||||||
meta.className = "chat-item-meta";
|
meta.className = "chat-item-meta";
|
||||||
const chatType = chat.assistant_persona_id
|
const chatType = chat.assistant_persona_id
|
||||||
@@ -1175,16 +1217,19 @@ function renderChatList() {
|
|||||||
await deleteChat(chat.id);
|
await deleteChat(chat.id);
|
||||||
});
|
});
|
||||||
content.append(title, meta);
|
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.append(content, remove);
|
||||||
item.addEventListener("click", () => {
|
content.addEventListener("click", () => {
|
||||||
closeMobileSidebar();
|
closeMobileSidebar();
|
||||||
setActiveChat(chat.id);
|
void setActiveChat(chat.id, {matchMessageId: query ? chat.matched_message_id : null});
|
||||||
});
|
|
||||||
item.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
|
||||||
event.preventDefault();
|
|
||||||
closeMobileSidebar();
|
|
||||||
void setActiveChat(chat.id);
|
|
||||||
});
|
});
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
@@ -1500,8 +1545,21 @@ async function stopGeneration() {
|
|||||||
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
|
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setActiveChat(id, {preserveComposer = false} = {}) {
|
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
|
||||||
if (!preserveComposer && id !== activeChatId) attachmentComposer.discard();
|
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();
|
if (isMobileLayout()) closeMobileSidebar();
|
||||||
setChatLoading(true, "Opening chat...");
|
setChatLoading(true, "Opening chat...");
|
||||||
setChatActivity(true, {
|
setChatActivity(true, {
|
||||||
@@ -1513,16 +1571,27 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
|
|||||||
history.replaceState(null, "", `/app/chats/${id}`);
|
history.replaceState(null, "", `/app/chats/${id}`);
|
||||||
messageRetryState = new Map();
|
messageRetryState = new Map();
|
||||||
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
|
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) {
|
if (res.error) {
|
||||||
setChatLoading(false);
|
setChatLoading(false);
|
||||||
setChatActivity(false);
|
setChatActivity(false);
|
||||||
return setNotice(res.error, "error");
|
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);
|
const existing = getChatById(id);
|
||||||
if (res.chat && existing) Object.assign(existing, res.chat);
|
if (res.chat && existing) Object.assign(existing, res.chat);
|
||||||
populateGenerationSettings(res.chat || existing);
|
populateGenerationSettings(res.chat || existing);
|
||||||
currentMessages = res.messages || []; renderMessages();
|
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();
|
updateSendState();
|
||||||
setChatLoading(false);
|
setChatLoading(false);
|
||||||
setChatActivity(false);
|
setChatActivity(false);
|
||||||
@@ -1585,7 +1654,6 @@ function scheduleChatSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createNewChat({preserveComposer = false} = {}) {
|
async function createNewChat({preserveComposer = false} = {}) {
|
||||||
if (!preserveComposer) attachmentComposer.discard();
|
|
||||||
setChatLoading(true, "Creating chat...");
|
setChatLoading(true, "Creating chat...");
|
||||||
setChatActivity(true, {
|
setChatActivity(true, {
|
||||||
eyebrow: "Creating chat",
|
eyebrow: "Creating chat",
|
||||||
@@ -1905,7 +1973,9 @@ async function deleteChat(id) {
|
|||||||
return setNotice(res.error, "error");
|
return setNotice(res.error, "error");
|
||||||
}
|
}
|
||||||
chatSessions = chatSessions.filter((item) => item.id !== id);
|
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();
|
messageRetryState = new Map();
|
||||||
await loadSummary(); renderChatList();
|
await loadSummary(); renderChatList();
|
||||||
if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat();
|
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.
|
* @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 {
|
import {
|
||||||
applyTheme as applySharedTheme,
|
applyTheme as applySharedTheme,
|
||||||
applyThemeMode as applySharedThemeMode,
|
applyThemeMode as applySharedThemeMode,
|
||||||
@@ -65,7 +65,6 @@ const settingsPopupConfirm = $("settingsPopupConfirm");
|
|||||||
const settingsPopupTitle = $("settingsPopupTitle");
|
const settingsPopupTitle = $("settingsPopupTitle");
|
||||||
const settingsPopupEyebrow = $("settingsPopupEyebrow");
|
const settingsPopupEyebrow = $("settingsPopupEyebrow");
|
||||||
const settingsPopupDescription = $("settingsPopupDescription");
|
const settingsPopupDescription = $("settingsPopupDescription");
|
||||||
const settingsPopupField = $("settingsPopupField");
|
|
||||||
const settingsPopupInputLabel = $("settingsPopupInputLabel");
|
const settingsPopupInputLabel = $("settingsPopupInputLabel");
|
||||||
const settingsPopupInput = $("settingsPopupInput");
|
const settingsPopupInput = $("settingsPopupInput");
|
||||||
const settingsPersonaFields = {
|
const settingsPersonaFields = {
|
||||||
@@ -500,9 +499,8 @@ usernameForm.addEventListener("submit", async (event) => {
|
|||||||
setNotice(res.error, "error");
|
setNotice(res.error, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentUsernameInput.value = res.username;
|
|
||||||
newUsernameInput.value = res.username;
|
|
||||||
usernamePasswordInput.value = "";
|
usernamePasswordInput.value = "";
|
||||||
|
if (!await loadProfile()) return;
|
||||||
setNotice("Username updated.", "success");
|
setNotice("Username updated.", "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -317,7 +317,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -381,7 +381,7 @@ export function createRepositories(db, config) {
|
|||||||
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
|
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
|
||||||
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
|
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
|
||||||
searchChats: db.prepare(`
|
searchChats: db.prepare(`
|
||||||
SELECT DISTINCT cs.id,
|
SELECT cs.id,
|
||||||
CASE
|
CASE
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
ELSE cs.title
|
ELSE cs.title
|
||||||
@@ -408,18 +408,23 @@ export function createRepositories(db, config) {
|
|||||||
cs.system_instruction,
|
cs.system_instruction,
|
||||||
cs.created_at,
|
cs.created_at,
|
||||||
cs.updated_at,
|
cs.updated_at,
|
||||||
|
cm.id AS matched_message_id,
|
||||||
cm.content AS matched_content
|
cm.content AS matched_content
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
||||||
LEFT JOIN personas up ON up.id = cs.user_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 = ?
|
WHERE cs.username = ?
|
||||||
AND (
|
AND (
|
||||||
LOWER(CASE
|
LOWER(CASE
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
ELSE cs.title
|
ELSE cs.title
|
||||||
END) LIKE ?
|
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
|
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) => {
|
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.updateUsername.run(nextUsername, currentUsername);
|
||||||
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
|
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
|
||||||
statements.updatePersonasUsername.run(nextUsername, currentUsername);
|
statements.updatePersonasUsername.run(nextUsername, currentUsername);
|
||||||
@@ -945,7 +953,7 @@ export function createRepositories(db, config) {
|
|||||||
listChats: (username) => statements.listChatSessions.all(username),
|
listChats: (username) => statements.listChatSessions.all(username),
|
||||||
searchChats(username, query) {
|
searchChats(username, query) {
|
||||||
const like = `%${String(query || "").trim().toLowerCase()}%`;
|
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,
|
countChats: (username) => statements.countChatSessions.get(username).count,
|
||||||
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
|
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();
|
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 () => {
|
test("admin diagnostics require admin and return model status", async () => {
|
||||||
const server = await startTestServer({
|
const server = await startTestServer({
|
||||||
config: {
|
config: {
|
||||||
@@ -772,9 +814,18 @@ test("chat search, organization, and workspace export work", async () => {
|
|||||||
jar
|
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});
|
const search = await request(server.baseUrl, "/chats/search?q=keyword", {jar});
|
||||||
assert.equal(search.status, 200);
|
assert.equal(search.status, 200);
|
||||||
assert.equal(search.json.chats.length, 1);
|
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`, {
|
const organize = await request(server.baseUrl, `/chats/${chatId}/organization`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|||||||
@@ -97,3 +97,32 @@ test("discarding a chat draft aborts upload, deletes temporary files, and serial
|
|||||||
composer.clear();
|
composer.clear();
|
||||||
assert.deepEqual(deletes, [`/attachments/${temporaryId}`], "Sent attachments are retained");
|
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